# مستندات فنی سگمنتیک

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

```
Ingest host      https://in.segmentic.net    key: wk_...   public, ships in your app
Management host  https://api.segmentic.net   key: sk_seg_...  secret, server side only
Panel            https://app.segmentic.net
```

هرگز کلید `sk_seg_` را در کد سمت مرورگر یا داخل اپ موبایل نگذارید.

---

# مستندات فنی سگمنتیک

> مسیر مستندات سگمنتیک: شروع، جمع‌آوری داده، پیام‌رسانی، تحلیل و مرجع توسعه‌دهنده.
> https://segmentic.net/docs

سگمنتیک رویدادهای محصول را جمع می‌کند، مخاطب می‌سازد و پیام را در کانال مناسب به دست هر مخاطب می‌رساند. اگر برای اولین بار سرویس را متصل می‌کنید، از [راه‌اندازی سریع](/docs/quickstart) شروع کنید.

## سه سطح {#surfaces}

هر سطح کلید و کاربرد مشخصی دارد:

- **ورود داده:** `https://in.segmentic.net` با کلید `wk_seg_...`، برای ثبت رویداد از سایت، اپ یا سرور
- **مدیریت:** `https://api.segmentic.net` با کلید `sk_seg_...`، برای مدیریت مخاطب، کمپین و گزارش از بک‌اند
- **پنل:** `https://app.segmentic.net` با حساب کاربری، برای تنظیم و مشاهده اطلاعات

کلید نوشتن عمدا عمومی است و ساخته شده که داخل جاوااسکریپت خودتان بنشیند. کاری که می‌کند این است: رویداد بنویسد، دستگاه ثبت کند، پوش مرورگر را مشترک کند و پیام درون‌سایتی بگیرد. نه پروفایل می‌خواند و نه کلید تازه می‌سازد. کلید مدیریتی درست نقطه مقابلش است: هرچه نقش و مجوزش اجازه بدهد در کل حساب انجام می‌دهد و هرگز نباید به مرورگر برسد. دو پیشوند برای همین از هم جدا شده‌اند که گزارش نشت کلید در یک نگاه تعیین تکلیف شود: دیدن `wk_` در یک باندل عمومی یعنی همه‌چیز طبق طراحی است، دیدن `sk_` در همان‌جا یعنی یک حادثه.

## شروع {#start}

- [راه‌اندازی سریع](/docs/quickstart): ساخت کلید و ثبت اولین رویداد
- [مفهوم‌ها](/docs/concepts): واژه‌های اصلی محصول و ارتباط آن‌ها

## جمع‌آوری داده {#collect}

- [طراحی رویداد](/docs/events) و [فرهنگ‌نامه رویدادها](/docs/event-dictionary): انتخاب نام و ویژگی‌های درست
- [پیاده‌سازی رهگیری](/docs/instrument): جای درست فراخوانی رویداد در محصول
- [هویت](/docs/identity): اتصال رفتار مهمان به کاربر واردشده
- [SDK وب](/docs/sdk-web): اتصال سایت و وب‌اپ
- [SDK اندروید](/docs/sdk-android): رویداد، هویت، پوش و پیام درون‌برنامه‌ای در اپ اندروید
- [سرور به سرور](/docs/server): ثبت رویدادهای قطعی مثل پرداخت موفق
- [ثبت دستگاه](/docs/devices): آماده‌سازی پوش موبایل و مرورگر
- [کاتالوگ محصولات](/docs/catalog) و [وب‌هوک‌ها](/docs/webhooks): همگام‌سازی داده با سرویس‌های دیگر

## مخاطب و پیام‌رسانی {#engage}

- [سگمنت‌ها](/docs/segments): ساخت مخاطب بر پایه رفتار و ویژگی
- [سناریوها](/docs/journeys): اجرای جریان‌های چندمرحله‌ای
- [پیام تراکنشی](/docs/transactional): ارسال امن پیام‌های فوری
- [رضایت و سقف ارسال](/docs/consent): رعایت ترجیحات مخاطب
- [پیام درون‌برنامه‌ای](/docs/onsite): بنر، مودال، نظرسنجی و صندوق پیام

## تحلیل و خروجی {#analyze}

- [گزارش و خروجی](/docs/reports): قیف، ماندگاری و دریافت فایل داده

## مرجع توسعه‌دهنده {#reference}

- [مرجع API](/docs/api): انتخاب میزبان، کلید و مسیر درست
- [نقاط ورود داده](/docs/api/ingest) و [API مدیریتی](/docs/api/management): جزئیات درخواست و پاسخ
- [کدهای خطا](/docs/errors) و [سقف‌ها](/docs/limits): رفتار قابل اتکای کلاینت
- [OpenAPI](/docs/openapi): سند ماشین‌خوان برای ساخت کلاینت

## ابزارهای توسعه {#tools}

- [کار با عامل هوش مصنوعی](/docs/ai): فایل یکپارچه مستندات و پرامپت آماده
- [سرور MCP](/docs/mcp): اجازه بدهید کلود، کدکس یا کرسر حساب شما را بخوانند و رویش کار کنند

## حریم خصوصی و تغییرات {#trust}

- [داده‌های شخصی](/docs/privacy): دسترسی، حذف و نگه‌داری داده
- [نسخه‌بندی](/docs/versioning): سازگاری API و شیوه اعلام تغییرها

## نسخه ماشین‌خوان {#machine-readable}

برای ابزارها و عامل‌های هوش مصنوعی، مستندات در قالب‌های [/llms.txt](/llms.txt)، [/llms-full.txt](/llms-full.txt)، [/docs-fa.md](/docs-fa.md) و [/docs-en.md](/docs-en.md) در دسترس است. سند OpenAPI نیز از [/openapi.json](/openapi.json) دریافت می‌شود.

---

# راه‌اندازی سریع: از کلید تا اولین رویداد

> در ده دقیقه یک کلید بسازید، اولین رویداد را بفرستید و ببینید که رسیده است. بدون SDK و فقط با یک درخواست.
> https://segmentic.net/docs/quickstart

برای این صفحه یک حساب سگمنتیک لازم است و یک ترمینال. نه SDK، نه npm، نه کتابخانه. در پایان صفحه یک رویداد فرستاده‌اید، در پنل دیده‌اید که رسیده، و آن را به یک کاربر مشخص وصل کرده‌اید.

> نمودار: مسیر کلی داده از منابع شما تا سگمنت، سناریو و گزارش در سگمنتیک

کار در مرورگر شروع می‌شود و این را اول می‌گوییم چون خلاف انتظار است: هیچ مسیر HTTP عمومی وجود ندارد که کلید بسازد. دو مسیری که کلید می‌سازند روی شنوندهٔ داخلی پنل‌اند که هیچ‌چیز بیرون شبکه نمی‌تواند آدرسش کند؛ خود پنل با نشست واردشدهٔ شما به آن می‌رسد. پس کلید اول از دل مرورگر بیرون می‌آید. ثبت‌نام خودکار هم وجود ندارد؛ حساب را اپراتور می‌سازد و شما با نام کاربری و رمز شروع می‌کنید. مسیر کامل، صادقانه، این است: دو بار کار در مرورگر و یک درخواست در ترمینال.

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

## کلید بسازید {#create-a-key}

وارد پنل شوید و به `https://app.segmentic.net/fa/connect` بروید. در منوی کنار، این صفحه زیر «تنظیمات» است و اسمش «SDK و اتصال» است. خود صفحه «اتصال» نام دارد و چهار قدم پشت سر هم دارد: اپ، کلید، نصب، بررسی.

**قدم اول، اپ.** یک نام بدهید (برای نمونه «سایت اصلی»)، پلتفرم را انتخاب کنید و «افزودن» را بزنید. پلتفرم‌هایی که می‌شود انتخاب کرد: `web`، `android`، `ios`، `windows`، `macos`، `linux` و `server`. هر سایت یا اپ یک ردیف جدا می‌شود، چون هر ردیف کلید مستقل خودش را دارد و ابطال یکی بقیه را از کار نمی‌اندازد.

**قدم دوم، کلید.** روی اپی که ساختید «کلید جدید» را بزنید. کلیدی که برمی‌گردد با `wk_seg_` شروع می‌شود و چهل‌وسه نویسه بعد از آن دارد، در مجموع پنجاه نویسه. کپی‌اش کنید، چون همین یک بار کامل نمایش داده می‌شود. اگر گمش کردید، کلید تازه بسازید؛ راهی برای دیدن دوبارهٔ کلید قبلی وجود ندارد و این عمدی است. از این لحظه فقط هش کلید ذخیره شده است، نه خود کلید.

این کلید داخل کد عمومی سایت شما می‌نشیند و فقط اجازهٔ نوشتن رویداد دارد: نه خواندن پرونده، نه ساخت کلید دیگر. کلیدی که برای API مدیریتی لازم است چیز دیگری است، با `sk_seg_` شروع می‌شود و جای دیگری ساخته می‌شود («تنظیمات» و بعد «کلیدهای API»). اگر آن را روی `in.segmentic.net` بفرستید پاسخ ۴۰۱ می‌گیرید.

> [!warn]
> فرم این صفحه فقط نام و پلتفرم می‌فرستد و بس، پس اپی که اینجا ساخته می‌شود همیشه `development` است. دو مقدار دیگر، `staging` و `production`، فقط در بدنهٔ `POST /v1/apps` تعیین می‌شوند که کنار مسیرهای کلید روی شنوندهٔ داخلی پنل نشسته است: نه میزبان ورود داده آن را سرو می‌کند و نه میزبان مدیریتی، و تنها مسیر عمومی به آن، پروکسی سمت سرور خود پنل است روی `https://app.segmentic.net/api/proxy/v1/apps` با کوکی نشست کاربر واردشده. پس کاربر واردشده‌ای که اجازهٔ ساخت کلید دارد می‌تواند اپ `production` بسازد، هرچند این فرم نمی‌سازد، و هیچ مسیری هم بعد از ساخت اپ، محیط آن را عوض نمی‌کند. برای خود داده هیچ فرقی هم ندارد: محیط روی اپ ذخیره می‌شود و کالکتور آن را نمی‌خواند. رویداد یک اپ توسعه به همان جایی می‌رود که رویداد یک اپ پروداکشن می‌رود. جدا نگه‌داشتن داده یعنی اپ جدا با کلید جدا، نه محیط متفاوت روی یک اپ.

## اولین رویداد {#first-event}

جای `wk_seg_...` کلید خودتان را بگذارید و این را در ترمینال اجرا کنید:

```bash title="اولین رویداد"
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "message_id": "qs-1",
        "event": "install_check",
        "anonymous_id": "quickstart-1",
        "properties": { "source": "curl" }
      }'
```

پاسخ:

```json
{ "status": "ok", "accepted": 1 }
```

`accepted` تعداد رویدادهایی است که پذیرفته شده‌اند. وقتی صفر باشد در پاسخ نمی‌آید، پس نبودنش یعنی هیچ رویدادی پذیرفته نشده است.

چند نکته دربارهٔ همین یک درخواست:

- مسیر تعیین می‌کند نوع پیام چیست. `/v1/track` فقط رویداد `track` می‌سازد و اگر در بدنه فیلد `type` بگذارید نادیده گرفته می‌شود.
- یکی از `user_id` یا `anonymous_id` اجباری است. اگر هیچ‌کدام نباشد پاسخ ۴۰۰ است با پیام `missing_identity`.
- روی `track`، فیلد `event` اجباری است. نبودش پاسخ ۴۰۰ می‌دهد با پیام `missing_event_name`.
- `timestamp` اختیاری است و اگر نفرستید زمان دریافت سرور ثبت می‌شود. اگر فرستادید باید `RFC 3339` باشد؛ ثانیهٔ یونیکس یا تاریخ خالی، JSON را خراب می‌کند و پاسخ ۴۰۰ می‌گیرد.
- زمان بیرون از پنجره رد نمی‌شود، کشیده می‌شود. قدیمی‌تر از پنجرهٔ نگهداری حساب به لبهٔ همان پنجره می‌رود با هشدار `timestamp_too_old`، و جلوتر از یک ساعت به زمان دریافت با هشدار `timestamp_in_future`. پاسخ در هر دو حالت ۲۰۰ است، پس اگر هشدارها را نخوانید هیچ‌وقت خبردار نمی‌شوید.
- شناسهٔ حساب و شناسهٔ اپ از خود کلید خوانده می‌شوند، نه از بدنه. `ip` و `user-agent` هم از خود اتصال گرفته می‌شوند، پس کلاینت نمی‌تواند موقعیت یا دستگاه خودش را جعل کند.
- کلید را می‌شود جای هدر `Authorization` در هدر `X-Segmentic-Key` یا در پارامتر `?write_key=` هم فرستاد. سومی برای بیکن تصویری و `sendBeacon` است که نمی‌توانند هدر بگذارند.

## هشدار در پاسخ {#warnings}

حالا همان درخواست را بدون `message_id` بفرستید:

```bash title="بدون شناسهٔ پیام"
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "event": "install_check",
        "anonymous_id": "quickstart-1"
      }'
```

پاسخ همچنان ۲۰۰ است، ولی این بار چیزی همراهش می‌آید:

```json
{
  "status": "ok",
  "accepted": 1,
  "warnings": [
    {
      "code": "generated_message_id",
      "field": "message_id",
      "note": "no message_id sent; retries of this event cannot be de-duplicated"
    }
  ]
}
```

هشدار یعنی رویداد پذیرفته شد ولی چیزی اصلاح شد. اینجا سرور خودش یک شناسه ساخته است، و دلیل هشدار این است: `message_id` همان چیزی است که تلاش دوباره را بی‌خطر می‌کند. SDK روی شبکهٔ موبایل با کوچک‌ترین قطعی دوباره می‌فرستد؛ بدون شناسهٔ ثابت، تعداد خریدهای مشتری بی‌صدا دو برابر می‌شود. سرور شناسه را می‌سازد تا رویداد از دست نرود، ولی علامت می‌زند که فرستنده ایراد دارد.

حذف تکراری در محدودهٔ همان حساب کار می‌کند و پنجره‌اش پیش‌فرض ۴۸ ساعت است. اگر همان `message_id` را دوباره بفرستید، باز هم ۲۰۰ و باز هم `accepted: 1` می‌گیرید و هیچ رویداد دومی ثبت نمی‌شود. پاسخ به‌عمد همان است: SDK که خطا بگیرد تا ابد دوباره می‌فرستد. تکراری هم شمارش نمی‌شود و در صورتحساب نمی‌آید.

## وقتی پاسخ ۲۰۰ نیست {#not-two-hundred}

| وضعیت | بدنه | معنی |
|---|---|---|
| ۴۰۰ | `{"status":"error","message":"malformed JSON"}` | بدنه JSON معتبر نیست |
| ۴۰۰ | `{"status":"error","message":"missing_identity"}` | نه `user_id` بود نه `anonymous_id`. متن پیام همان کد است |
| ۴۰۱ | `{"status":"error","message":"missing write key"}` | هیچ کلیدی در هدر و کوئری نبود |
| ۴۰۱ | `{"status":"error","message":"invalid write key"}` | کلید ناشناخته، باطل‌شده، یا حسابی که تعلیق شده. هر سه یک پاسخ می‌گیرند تا نشود با این مسیر وجود کلیدها را حدس زد |
| ۴۰۲ | `{"status":"error","message":"..."}` با یک جملهٔ فارسی | سقف حساب پر شده است. تا وقتی کسی تصمیم مالی نگیرد چیزی عوض نمی‌شود، پس تلاش دوباره بی‌فایده است |
| ۴۱۳ | `{"status":"error","message":"request body too large"}` | بدنه از ۵ مگابایت (`5242880` بایت) بزرگ‌تر بود |
| ۵۰۳ | `{"status":"error","message":"cannot verify the write key right now; retry"}` | پایگاه داده برای بررسی کلید در دسترس نبود. هدر `Retry-After: 5` هم می‌آید |
| ۵۰۳ | `{"status":"error","message":"temporarily unavailable, please retry"}` | هم گذرگاه پیام و هم بافر روی دیسک شکست خوردند |

فرق ۴۰۱ و ۵۰۳ عمدی است و از یک خرابی واقعی درآمده. SDK، عدد ۴۰۱ را دائمی می‌فهمد و رویداد را دور می‌ریزد؛ ۵۰۳ را گذرا می‌فهمد و نگه می‌دارد. یک بار پستگرس خاموش شد و بررسی کلید همان ۴۰۱ را برگرداند، پس هشت رویداد از هشت رویداد دور ریخته شد در حالی که کل بافر روی دیسک درست برای همین حالت ساخته شده بود. حالا خرابی سمت ما همیشه ۵۰۳ است.

متن پیام ۴۰۲ همیشه فارسی است. این میزبان هیچ میان‌افزار زبانی ندارد، پس هدر `Accept-Language` روی آن اثری ندارد.

روی میزبان ورود داده هیچ محدودیت نرخی وجود ندارد. تنها چیزی که حجم را کنترل می‌کند سقف رویداد همان اشتراک است که پاسخش ۴۰۲ است.

هر پاسخ، چه ۲۰۰ باشد چه خطا، هدر `X-Segmentic-Trace` دارد: شانزده نویسهٔ هگز که همان یک درخواست را در لاگ ما پیدا می‌کند. در هیچ بدنهٔ خطایی نمی‌آید، پس باید همان موقع از هدر برش دارید. اگر خودتان مقدار معتبری بفرستید، همان برمی‌گردد و دو طرف یک شناسه دارند. وقت باز کردن تیکت، همین یک عدد را بفرستید.

## دیدن رویداد در پنل {#see-it-arrive}

دو جا می‌شود دید که رویداد رسیده، و هر کدام به یک سؤال جواب می‌دهند.

**قدم چهارم همان صفحهٔ «اتصال»، یعنی «بررسی».** این بخش هر چهار ثانیه خودش وضعیت را می‌پرسد و لازم نیست صفحه را تازه کنید. جوابی که می‌دهد کوچک است و همین کافی است: آیا این اپ تا حالا رویدادی تحویل داده، چه زمانی، و چند تا در ۲۴ ساعت گذشته. چند نام آخر رویدادها را هم نشان می‌دهد تا ببینید چیزی که رسیده همانی است که فرستادید. صفر هم یک جواب واقعی است، نه یک صفحهٔ خالی.

**«رویدادهای زنده»، در `https://app.segmentic.net/fa/debug`.** در منو زیر «اتصال‌ها و یکپارچه‌سازی» است و «تست زنده اتصال» نام دارد. اینجا هر رویداد را با نام، شناسهٔ کاربر، مقدارهای ارسال‌شده و هشدارهای همان رویداد می‌بینید.

> [!note]
> ترتیب مهم است: اول ضبط را در صفحهٔ «رویدادهای زنده» روشن کنید، بعد رویداد را بفرستید. تا وقتی کسی تماشا نمی‌کند، کالکتور چیزی برای دیباگر نمی‌نویسد، چون یک نوشتن به‌ازای هر رویداد برای حالتی که هیچ‌کس نگاه نمی‌کند، گران‌تر از خود کاری است که می‌خواهد انجام دهد. با بستن صفحه، ضبط متوقف می‌شود.

> [!warn]
> «رویدادهای زنده» فقط مسیرهای تک‌رویدادی را ضبط می‌کند: `/v1/track`، `/v1/identify`، `/v1/page`، `/v1/screen` و `/v1/alias`. مسیر `POST /v1/batch` ضبط نمی‌شود، و هر SDK ما دقیقا از همان مسیر می‌فرستد. یعنی بعد از نصب SDK این صفحه خالی می‌ماند حتی وقتی رویدادها بی‌عیب می‌رسند. برای نصب SDK، جواب را از قدم «بررسی» صفحهٔ «اتصال» بگیرید.

اگر رویدادی که با curl فرستادید در این صفحه نیامد، مشکل در فرستادن است نه در گزارش‌ها.

## وصل کردن رویداد به یک نفر {#identify}

تا اینجا رویداد به `quickstart-1` تعلق دارد، که یک شناسهٔ ناشناس است. `identify` همان چیزی است که یک آدم مشخص را با ویژگی‌هایش می‌سازد:

```bash title="ساختن یک پرونده"
curl -X POST https://in.segmentic.net/v1/identify \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "message_id": "qs-2",
        "user_id": "u_123",
        "anonymous_id": "quickstart-1",
        "traits": {
          "email": "Ali@Digikala.COM",
          "phone": "09123456789",
          "city": "شيراز",
          "key_balance": 428
        }
      }'
```

پاسخ:

```json
{ "status": "ok", "accepted": 1 }
```

چهار اتفاق افتاد که در پاسخ دیده نمی‌شوند:

- `email` فقط کوچک و trim شد و به `ali@digikala.com` تبدیل شد. هیچ اعتبارسنجی قالبی روی ایمیل انجام نمی‌شود.
- `phone` به شکل `E.164` ذخیره شد، یعنی `+989123456789`، و یک ویژگی دوم به‌نام `phone_operator` با مقدار `mci` هم نوشته شد. اگر شماره معتبر نبود، مقدار خام ذخیره می‌شد، `phone_operator` نوشته نمی‌شد و هشدار `invalid_phone` برمی‌گشت.
- `city` نرمال شد: یای عربی به یای فارسی. بدون این کار، سگمنتی که روی «شیراز» شرط می‌گذارد کاربرانی را که با کیبورد عربی تایپ شده‌اند بی‌صدا جا می‌اندازد.
- `key_balance` دو بار نوشته شد: یک بار رشته با مقدار `"428"` و یک بار عدد با مقدار `428`. نیمهٔ عددی همان چیزی است که شرط «بیشتر از ۱۰۰» را جواب می‌دهد. رشتهٔ عددی هرگز به عدد تبدیل نمی‌شود، چون `"0912..."` صفر ابتدایی‌اش را از دست می‌دهد و یک کد ملی بزرگ‌تر از توان پنجاه‌وسه، رقم آخرش را.

> [!warn]
> فرستادن `anonymous_id` کنار `user_id` روی `identify`، رویداد قبلی را به این پرونده وصل نمی‌کند. پیوند هویت فقط با `POST /v1/alias` نوشته می‌شود، و آن هم فقط یک ردیف پیوند ثبت می‌کند: رویدادهایی که از قبل ذخیره شده‌اند تا ابد `user_id` خالی می‌مانند. یعنی قیفی که با یک بازدید ناشناس شروع می‌شود و با یک خرید واردشده تمام می‌شود، این دو را به هم وصل نمی‌کند. جزئیات کامل و کاری که می‌شود کرد در [هویت](/docs/identity).

بعد از این درخواست یک پرونده با شناسهٔ `u_123` وجود دارد. پیش از آن هیچ پرونده‌ای وجود نداشت: کاربر ناشناس ردیف پرونده نمی‌گیرد.

## همان درخواست از جاوااسکریپت {#javascript}

حالا که رویداد را با چشم خودتان دیده‌اید، نوبت SDK است.

بستهٔ `@segmentic/web` روی هیچ رجیستری منتشر نشده و `npm install @segmentic/web` شکست می‌خورد. راهی که امروز کار می‌کند تگ اسکریپت است، از همان میزبانی که رویداد به آن می‌رود، پس در سیاست امنیتی محتوای سایتتان فقط یک مبدأ اضافه می‌شود:

```html title="در انتهای head سایت"
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net"
  });
  Segmentic.track("install_check", { source: "browser" });
</script>
```

سه چیزی که اگر ندانید فکر می‌کنید کار نکرده است:

- SDK بلافاصله نمی‌فرستد. تا بیست پیام یا تا ده ثانیه صبر می‌کند، هرکدام زودتر برسد. `await Segmentic.flush()` صف را همان لحظه خالی می‌کند، ولی مقصدش `POST /v1/batch` است، پس نتیجه‌اش در «رویدادهای زنده» دیده نمی‌شود. تأیید رسیدن را از قدم «بررسی» صفحهٔ «اتصال» بگیرید.
- `init` خودش یک بازدید صفحه می‌فرستد، چون `autoPageView` پیش‌فرض روشن است.
- اگر مرورگر «ردیابی نکن» را روشن کرده باشد هیچ‌چیز فرستاده نمی‌شود، چون `respectDoNotTrack` پیش‌فرض روشن است. این اولین چیزی است که موقع «هیچ رویدادی نمی‌آید» باید بررسی کنید.

بقیهٔ متدها، صف آفلاین و پوش مرورگر در [SDK وب](/docs/sdk-web).

## همان درخواست از سرور {#server}

همان کلید نوشتن از بک‌اند هم کار می‌کند. با پایتون:

```python title="ارسال از پایتون"
import requests

response = requests.post(
    "https://in.segmentic.net/v1/track",
    headers={"Authorization": "Bearer wk_seg_..."},
    json={
        "message_id": "qs-3",
        "event": "order_completed",
        "user_id": "u_123",
        "properties": {"revenue": 2500000, "currency": "IRR"},
    },
    timeout=10,
)
print(response.status_code, response.json())
```

خروجی:

```text
200 {'status': 'ok', 'accepted': 1}
```

`order_completed` یکی از یازده نام استاندارد است و همان چیزی است که قیف‌ها و سناریوهای آماده روی آن سوارند. `revenue` هم از روی همین ویژگی برداشته می‌شود؛ اگر نبود، `total` و بعد `value` و در آخر `price` ضربدر `quantity` امتحان می‌شوند. واحد پول اگر گفته نشود `IRR` است و هیچ تبدیلی حدس زده نمی‌شود.

برای رویدادی که فقط بک‌اند شما از آن مطمئن است، یک در دوم هم هست: `POST /v1/events` روی `https://api.segmentic.net` با کلید `sk_seg_`. آن در دو تفاوت دارد که هر دو بی‌صدا هستند: تکراری‌ها را حذف نمی‌کند، پس همان `message_id` دو بار فرستاده‌شده دو رویداد می‌شود؛ و پنجرهٔ زمانش ثابت سی روز است، پس هر زمانی قدیمی‌تر از آن به لبهٔ سی روز کشیده می‌شود و هشدارش هم دور ریخته می‌شود. برای همین است که تاریخچه را از این در منتقل نکنید. تفاوت‌ها در [سرور به سرور](/docs/server).

## بعد چه بخوانید {#next}

- [مفهوم‌ها](/docs/concepts)، اگر می‌خواهید بدانید سگمنت و مخاطب و سناریو چه فرقی با هم دارند.
- [تعریف رویداد](/docs/events)، پیش از آنکه نام رویدادها را قطعی کنید. نام غلط بعد از این اصلاح نمی‌شود.
- [گذاشتن رویداد](/docs/instrument)، برای رفتن از این یک رویداد آزمایشی به رویدادهای واقعی سایت یا اپ خودتان.
- [هویت](/docs/identity)، اگر کاربر مهمان دارید و بعد وارد می‌شود.
- [SDK وب](/docs/sdk-web) یا [SDK اندروید](/docs/sdk-android) برای نصب واقعی.
- [نقاط ورود داده](/docs/api/ingest) برای بقیهٔ مسیرها: دسته‌ای، ثبت دستگاه، صندوق پیام.
- [کدهای خطا](/docs/errors) و [سقف‌ها](/docs/limits) وقتی می‌خواهید ارسال را مقاوم کنید.

---

# مفهوم‌ها: رویداد، پروفایل، سگمنت، کمپین، سناریو

> ده مفهومی که بقیهٔ مستندات روی آن‌ها سوار است، هرکدام در یک بند، با نامی که در پنل و در API یکی است.
> https://segmentic.net/docs/concepts

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

هرجا این صفحه می‌گوید چیزی «نیست»، یعنی وجود ندارد؛ نه اینکه هنوز مستند نشده باشد.

| مفهوم | در پنل | روی سیم و در انگلیسی |
|---|---|---|
| رویداد | رویداد | `event` |
| پرونده | پرونده، و در منو «کاربران» | `profile` |
| ویژگی | ویژگی | `trait` |
| سگمنت | سگمنت | `segment` |
| مخاطب | مخاطب | `audience` |
| کمپین | کمپین | `campaign` |
| سناریو | سناریو | `journey` |
| کانال | کانال | `channel` |
| کلید نوشتن | کلید نوشتن | `write key` |
| کلید API | کلیدهای API | `API key` |

## رویداد {#event}

رویداد یک اتفاق است که یک بار افتاده، در یک لحظه، برای یک هویت. نامی دارد، زمانی دارد، و می‌تواند ویژگی‌هایی همراهش بیاید. پنج نوع پیام وجود دارد و مسیر درخواست تعیین می‌کند کدام است: `track`، `identify`، `page`، `screen` و `alias`. دو جا خودتان نوع را می‌نویسید: آیتم‌های `POST /v1/batch` روی میزبان ورود داده، و آیتم‌های `POST /v1/events` روی میزبان مدیریتی. در هر دو، نوعی که نشناسیم رویداد را با `unknown_type` رد می‌کند.

نام رویداد بعد از نرمال‌سازی فارسی حداکثر ۱۲۸ بایت است و نویسهٔ کنترلی نمی‌پذیرد. هیچ فهرست مجاز، هیچ یکسان‌سازی حروف بزرگ و کوچک و هیچ اجبار به snake_case وجود ندارد. نام فارسی هم پذیرفته می‌شود. نتیجهٔ مستقیمش این است که `Order Completed` و `order_completed` تا ابد دو رویداد جدا می‌مانند و هیچ خطایی هم نمی‌گیرید. یازده نام استاندارد وجود دارد که قیف‌ها و سناریوهای آماده روی آن‌ها سوارند: `product_viewed`، `product_added_to_cart`، `product_removed_from_cart`، `cart_viewed`، `checkout_started`، `order_completed`، `order_refunded`، `order_cancelled`، `searched`، `signed_up` و `signed_in`.

ویژگی‌های رویداد حداکثر ۲۵۶ تا نگه داشته می‌شوند. کلیدها نرمال می‌شوند: فاصله و نقطه و خط تیره به زیرخط تبدیل می‌شوند. مقدارها دو جا می‌نشینند، یک نقشهٔ رشته‌ای و یک نقشهٔ عددی، چون نقشه در انبار داده باید هم‌نوع باشد. مقدار `null` ذخیره نمی‌شود، چون «تنظیم نشده» با «رشتهٔ خالی» یکی نیست و اگر یکی می‌شد، شرط «تنظیم نشده» غلط جواب می‌داد.

رویداد چه چیزی نیست: رویداد وضعیت نیست. «کاربر اکنون اشتراک طلایی دارد» ویژگی است نه رویداد؛ «کاربر اشتراک طلایی خرید» رویداد است. رویداد ذخیره‌شده هم قابل ویرایش نیست. هیچ endpointی برای ویرایش یا حذف یک رویداد وجود ندارد و سه چیز سطر را برمی‌دارد: سیاست نگهداری تنانت شما، TTL ۴۰۰ روزهٔ خود جدول `events`، و پاک‌سازی دادهٔ یک شخص که درخواست نامش را می‌برد.

## پرونده و ویژگی {#profile-and-trait}

پرونده یک ردیف است به‌ازای هر `user_id` در هر حساب، که از تاشدن رویدادهای همان کاربر ساخته می‌شود. در منوی پنل این بخش «کاربران» نام دارد و خود صفحه از واژهٔ «پرونده» استفاده می‌کند. ویژگی، چیزی است که دربارهٔ آن آدم درست است: ایمیل، شهر، موجودی، تاریخ ثبت‌نام. ویژگی‌ها با `identify` می‌آیند.

چند ویژگی رفتار خاص دارند: `email` فقط کوچک و trim می‌شود، `phone` به `E.164` تبدیل می‌شود و یک `phone_operator` هم از رویش ساخته می‌شود، `national_id` اگر رقم کنترلی‌اش درست نباشد یکسره کنار گذاشته می‌شود، و `gender` به یکی از سه مقدار `male`، `female` یا `other` نگاشته می‌شود. بقیهٔ ویژگی‌ها آزادند و دو بار نوشته می‌شوند، یک بار رشته و یک بار عدد. فقط نیمهٔ عددی است که به شرط «بزرگ‌تر از» جواب می‌دهد؛ این دوباره‌نویسی از یک خرابی واقعی درآمد که در آن «موجودی ۱۰۰ یا بیشتر» هیچ‌کس را برنمی‌گرداند و «کمتر از ۱۰» هر ۱۱۴۹۴۳ پرونده را برمی‌گرداند، از جمله کسی که موجودی‌اش ۴۲۸ بود، بدون خطا و بدون هشدار.

نگفتن یک ویژگی یعنی «دست نزن». فرستادن رشتهٔ خالی هم یعنی «دست نزن»، نه «پاکش کن»: مقدار خالی پیش از ساخته‌شدن رویداد کنار گذاشته می‌شود و هرگز به پرونده نمی‌رسد. لایهٔ پرونده قاعدهٔ «پاک کن» را دارد و هیچ مسیر مشتری‌رویی به آن نمی‌رسد؛ گرهٔ «تغییر ویژگی» در سناریو هم مقدار خالی را با پیام صریح رد می‌کند. یعنی راه پشتیبانی‌شده‌ای برای پاک‌کردن یک ویژگی وجود ندارد و تنها حذف واقعی، همان مسیر پاک‌کردن دادهٔ شخصی است که کل پرونده را می‌برد. پروندهٔ تازه هر سه رضایت ارسال (ایمیل، پیامک، پوش) را روشن شروع می‌کند، چون پایگاه مشتری که وارد سیستم می‌شود پیش از این از مسیر خود مشتری رضایت داده و پیش‌فرض خاموش یعنی اولین کمپین به هیچ‌کس نمی‌رسد.

پرونده چه چیزی نیست: کاربر ناشناس پرونده ندارد. تا وقتی رویدادی با `user_id` نیاید، هیچ ردیفی ساخته نمی‌شود.

## هویت {#identity}

سه شناسه وجود دارد. `anonymous_id` را SDK می‌سازد و نگه می‌دارد. `user_id` را سامانهٔ ورود خودتان می‌دهد. `previous_id` فقط روی پیام `alias` معنی دارد و عمرش یک پیام است. دست‌کم یکی از دو تای اول اجباری است، و همان است که کلید پارتیشن‌بندی می‌شود تا همهٔ پیام‌های یک آدم به ترتیب دیده شوند.

`alias` تاریخچهٔ ناشناس را به کاربر منتقل نمی‌کند. کاری که می‌کند فقط یک چیز است: یک ردیف در نقشهٔ هویت می‌نویسد که می‌گوید این شناسهٔ ناشناس متعلق به این کاربر بوده. هیچ‌جا رویدادهای قبلی بازنویسی نمی‌شوند؛ آن ردیف‌ها تا ابد `user_id` خالی دارند، و هیچ گزارشی نقشهٔ هویت را join نمی‌کند. یعنی قیفی که با یک `product_viewed` ناشناس شروع می‌شود و با یک `order_completed` واردشده تمام می‌شود، این دو را یکی نمی‌بیند. تنها مصرف‌کنندهٔ واقعی نقشهٔ هویت، مسیر پاک‌کردن دادهٔ شخصی است، که با آن رویدادهای پیش از ورود همان آدم را هم پیدا و حذف می‌کند.

روی خروج از حساب `reset()` را صدا بزنید. اگر نزنید، نفر بعدی روی همان دستگاه شناسهٔ ناشناس نفر قبلی را به ارث می‌برد و ردیف نقشهٔ هویت هم جای قبلی را می‌گیرد. پیامدش این است که اگر بعد از آن نفر اول درخواست حذف داده بدهد، رویدادهای ناشناسش پیدا نمی‌شوند.

## سگمنت {#segment}

سگمنت شیء ذخیره‌شده است: یک فیلتر با نام و شناسه که در پنل می‌ماند و کمپین‌ها و سناریوها به آن ارجاع می‌دهند. تعریفش SQL نیست، یک درخت JSON است؛ رابط کاربری هیچ‌وقت SQL نمی‌فرستد و همین مرز است که اجازه می‌دهد این قابلیت دست بازاریاب باشد.

سه نوع در پایگاه داده تعریف شده است. `dynamic` پیش‌فرض است و هیچ‌چیز ذخیره‌شده‌ای ندارد: هر بار که کسی می‌شمارد یا کمپینی رویش راه می‌افتد، تعریف دوباره اجرا می‌شود. `static` فهرستی است که کسی آدم‌ها را داخلش گذاشته، مثل اکسل یک آژانس یا برندگان یک قرعه‌کشی، و عضویتش ردیف‌های واقعی است. `dynamic` تعریف می‌خواهد و `static` نمی‌خواهد. نوع سوم، `realtime`، هم API و هم محدودیت پایگاه داده آن را می‌پذیرند و هیچ بخشی از سرور آن را پیاده نکرده است. آن را رزروشده بدانید نه کارکننده.

سگمنت چه چیزی نیست: برای سگمنت پویا هیچ عضویت ذخیره‌شده‌ای و هیچ کار زمان‌بندی‌شدهٔ به‌روزرسانی وجود ندارد. اندازهٔ آخر و زمان محاسبهٔ آخر هم روی هیچ نصب واقعی نوشته نمی‌شوند، پس کارت سگمنت در کتابخانه همیشه می‌گوید اندازه محاسبه نشده است. اندازه را همان لحظه که می‌پرسید حساب می‌کنند.

## مخاطب {#audience}

مخاطب مجموعهٔ آدم‌هایی است که یک ارسال به آن‌ها می‌رسد. در پنل، انتخاب مخاطب یک کمپین همین است. روی API مدیریتی همین واژه اسم دو مسیر بی‌حافظه است: `POST /v1/audiences/validate` که فقط می‌گوید تعریف شما کامپایل می‌شود یا نه، و `POST /v1/audiences/count` که تعداد را برمی‌گرداند. هر دو تعریف را در بدنه می‌گیرند و هیچ‌چیز ذخیره نمی‌کنند.

فرق عملی همین است: سگمنت اسم چیزی است که ذخیره می‌کنید، مخاطب اسم مجموعهٔ آدم‌هایی است که از یک فیلتر بیرون می‌آید، چه آن فیلتر ذخیره شده باشد چه نه. هر دو واژه یک زبان فیلتر دارند.

این دوگانگی واژه در خود محصول هم هست و پنهانش نمی‌کنیم: مسیر شمارش `/v1/audiences/count` است ولی شیء ذخیره‌شده زیر `/v1/segments` زندگی می‌کند و پنل به آن «سگمنت» می‌گوید. روی API مدیریتی مسیر تخمین وجود ندارد. شمارندهٔ زندهٔ پنل `POST /v1/segments/estimate` را صدا می‌زند که روی کنترل‌پلین داشبورد ثبت شده و آن پورت به عمد از اینترنت مسیریابی نمی‌شود. از سرور خودتان، مسیری که جواب می‌دهد `POST /v1/audiences/count` است و به‌جای نمونه‌گیری، دقیق می‌شمارد.

## کمپین {#campaign}

کمپین یک پیام است به یک مخاطب، یک بار یا طبق زمان‌بندی. وضعیت‌هایش این‌ها هستند: `draft`، `scheduled`، `running`، `paused`، `completed`، `cancelled` و `failed`؛ سه تای آخر پایانی‌اند و از آن‌ها برگشتی نیست.

روی API مدیریتی می‌شود کمپین‌ها را فهرست کرد، یکی را خواند، ساخت، فرستاد و برای تأیید ثبت کرد. فهرست، دویست کمپین آخر بر اساس زمان تغییر است و نه بیشتر. این سقف در پاسخ اعلام نمی‌شود: نه شمارشی، نه `has_more`، نه مکان‌نما. حسابی که دویست‌ویکمین کمپین را دارد، آن را روی این سطح نمی‌بیند و راهی هم برای رفتن جلوتر نیست. همین برای `GET /v1/segments` هم برقرار است. مکث، ادامه و لغو روی آن سطح وجود ندارند؛ این سه فقط در پنل هستند. اگر یک درخواست تغییر وضعیت با هیچ ردیفی جور درنیاید پاسخ ۴۰۴ است، و سه دلیل جداگانه (مال شما نیست، وجود ندارد، در وضعیت اشتباه است) به‌عمد یک جواب می‌گیرند.

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

## سناریو {#journey}

سناریو گرافی است که هر کاربر تک‌تک و با حالت خودش از آن رد می‌شود. در پنل «سناریو» نام دارد و همین یک نام است: چند صفحهٔ داشبورد قبلاً «ژورنی» می‌نوشتند، که مثل مفهوم دومی خوانده می‌شد نه همان مفهوم.

گره‌ها هشت نوع‌اند: `trigger`، `wait`، `condition`، `switch`، `split`، `action`، `goal` و `exit`. قاعدهٔ ورود یکی از `once`، `every_time` یا `max_n` است. شرط خروج، آدم را همان لحظه‌ای که شرط برقرار شود بیرون می‌برد، هرجای گراف که باشد. نسخه‌های منتشرشده تغییرناپذیرند و تعریف سگمنت‌ها داخلشان کپی می‌شود نه ارجاع، تا نسخهٔ منتشرشده امروز همان معنایی را بدهد که روز انتشارش داشت.

سناریو چه چیزی نیست: روی API مدیریتی هیچ مسیر سناریویی وجود ندارد. `GET /v1/capabilities` کلید `journeys` را زیر `features` گزارش می‌کند، ولی آن پرچم فقط می‌گوید این نصب زیرسیستم سناریو را وصل کرده یا نه؛ حتی وقتی درست است، روی آن میزبان هیچ مسیری را روشن نمی‌کند. هیچ‌کدام از پرچم‌های `features` مقدار ثابتی ندارند و از یک نصب به نصب دیگر فرق می‌کنند، پس مقدارشان را در کد ننویسید و همان لحظه بپرسید. ساخت، انتشار و ورود دستی افراد فقط از پنل انجام می‌شود.

## کانال {#channel}

کانال، راهی است که پیام از آن می‌رود. رشته‌هایی که وجود دارند: `push` (پوش موبایل)، `webpush` (پوش مرورگر)، `sms`، `email`، `inapp` (صندوق داخل اپ)، `messenger`، و سه پیام‌رسان `bale`، `eitaa` و `rubika`.

سه پیام‌رسان را مستقیم روی کمپین نمی‌نویسید: هرکدام را بنویسید به `messenger` ارتقا داده می‌شود و موقع ارسال، برای هر گیرنده جداگانه حل می‌شود. در دسترس بودن هر کانال فقط از روی تنظیمات حساب می‌شود، بدون اینکه چیزی امتحان شود: برای نمونه `email` وقتی در دسترس است که هم میزبان SMTP و هم نشانی فرستنده تنظیم شده باشند. کانال در دسترس نبودن، با دلیلش نشان داده می‌شود نه با پنهان شدن.

یک حفره را صریح می‌گوییم: `webhook` به‌عنوان کانال روی کمپین پذیرفته می‌شود ولی هیچ فرستنده‌ای برایش ساخته نشده. کمپینی که روی `webhook` نوشته شود برای کل مخاطب شکست می‌خورد. اگر می‌خواهید سناریو به سرویس خودتان درخواست بزند، آن یک نوع «کنش» در سناریو است، نه این کانال.

## کلید نوشتن در برابر کلید API {#keys}

دو نوع کلید وجود دارد و هیچ‌کدام جای دیگری کار نمی‌کند.

| | کلید نوشتن | کلید API |
|---|---|---|
| پیشوند | `wk_seg_` | `sk_seg_` |
| میزبان | `https://in.segmentic.net` | `https://api.segmentic.net` |
| کجا می‌نشیند | داخل کد عمومی سایت یا اپ | فقط سمت سرور |
| چه می‌تواند بکند | نوشتن رویداد، ثبت دستگاه، اشتراک پوش، گرفتن پیام‌های درون‌برنامه‌ای | هر چیزی که نقش و مجوزهایش اجازه بدهد |
| چه نمی‌تواند بکند | خواندن پرونده، ساخت کلید دیگر | استفاده روی میزبان ورود داده |
| کجا ساخته می‌شود | «SDK و اتصال» | «تنظیمات» و بعد «کلیدهای API» |

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

اگر کلید نوشتن را روی API مدیریتی بفرستید، پاسخ ۴۰۱ است با کد اختصاصی `write_key_rejected` و پیامی که می‌گوید این کلید SDK است و اینجا کلید مدیریتی لازم است. این خطا کد جدا دارد چون اشتباه رایج است و پاسخ عمومی، آدم را دنبال مشکل اشتباه می‌فرستد. برعکسش کد جدا ندارد.

مجوزها روی کلید API از نقشش می‌آیند. کلیدی که نقش `owner` داشته باشد ساخته نمی‌شود، پس هیچ کلیدی نمی‌تواند حساب را منتقل یا حذف کند. ستون محدودکردن مجوزهای یک کلید در پایگاه داده هست ولی هیچ کدی در آن نمی‌نویسد؛ یعنی هر کلیدی که محصول امروز می‌سازد کل نقشش را دارد و `scoped` در پاسخ `GET /v1/whoami` همیشه `false` است.

آخرین تفاوت، سقف مصرف است. API مدیریتی برای هر کلید در هر دقیقهٔ تقویمی یک بودجهٔ وزنی دارد که پیش‌فرضش ۶۰۰ واحد است: یک `whoami` یک واحد و یک گزارش ماندگاری بیست‌وپنج واحد. میزبان ورود داده هیچ محدودیت نرخی ندارد.

---

# تعریف رویداد: چه چیزی بفرستیم و با چه نامی

> قاعده نام‌گذاری، انتخاب ویژگی‌ها و ساخت یک tracking plan پایدار برای گزارش و سگمنت.
> https://segmentic.net/docs/events

رویداد چیزی است که یک نفر انجام داد. اسم دارد، لحظه دارد، و چند ویژگی دارد که همان لحظه درست بودند. تمام تصمیم‌های بعدی شما، هر سگمنت، هر کمپین، هر قیف، روی همین سه چیز سوار می‌شود؛ و بعضی از این تصمیم‌ها را نمی‌شود پس گرفت. این صفحه دقیقا می‌گوید کد چه کاری با آنچه می‌فرستید می‌کند. اگر دنبال دستور کار عملی هستید، یعنی اینکه فراخوانی را کجای کد خودتان بگذارید و چطور بررسی کنید که رسیده، [گذاشتن رویداد](/docs/instrument) همان صفحه است.

> نمودار: مسیر رویداد از SDK وب، موبایل و سرور تا پرونده، سگمنت و شروع سناریو

## رویداد چیست و چه چیزی نیست {#what-an-event-is}

رویداد وضعیت نیست. «موجودی کیف پول این کاربر ۱۲۰۰۰ تومان است» رویداد نیست، ویژگی پرونده است و با `identify` فرستاده می‌شود. «کاربر کیف پولش را ۱۲۰۰۰ تومان شارژ کرد» رویداد است. فرق این دو در بخش [ویژگی رویداد یا ویژگی پرونده](/docs/events#property-or-trait) کامل باز شده، چون بیشترین اشتباه همان‌جا اتفاق می‌افتد.

رویداد بعد از نوشته‌شدن عوض نمی‌شود. هیچ endpointی برای ویرایش یا حذف یک رویداد وجود ندارد. سه چیز سطر را از جدول برمی‌دارد: سیاست نگهداری تنانت شما، TTL چهارصدروزه خود جدول رویدادها، و پاک‌سازی یک شخص شناسایی‌شده. یعنی هر تصمیمی که این صفحه از شما می‌خواهد، روی داده‌ای اثر دارد که از این لحظه به بعد می‌آید، نه روی چیزی که دیروز آمده است.

ترافیک ربات دور ریخته نمی‌شود. ذخیره می‌شود و ستون `is_bot` روی آن یک می‌شود، و گزارش‌ها به‌صورت پیش‌فرض کنارش می‌گذارند. اگر عددی را دور ریخته بودیم، بعدا هیچ‌کس نمی‌توانست ثابت کند که آن روز واقعا چه اتفاقی افتاده است.

یک رویداد باید دست‌کم یکی از `user_id` یا `anonymous_id` را داشته باشد، وگرنه با کد `missing_identity` رد می‌شود. هر دو حداکثر ۲۵۶ بایت‌اند.

## پنج نوع پیام {#message-types}

پنج نوع پیام وجود دارد و هرکدام مسیر خودش را دارد. نام رویدادی که ذخیره می‌شود، همیشه از خود شما نمی‌آید:

| مسیر | نوع | نامی که ذخیره می‌شود |
|---|---|---|
| `POST /v1/track` | track | همان `event` که فرستاده‌اید. اجباری است |
| `POST /v1/page` | page | همان `event`، و اگر خالی باشد `page_viewed` |
| `POST /v1/screen` | screen | همان `event`، و اگر خالی باشد `screen_viewed` |
| `POST /v1/identify` | identify | همیشه `identify`. هرچه در `event` بفرستید دور ریخته می‌شود |
| `POST /v1/alias` | alias | همیشه `alias`. مثل بالا |
| `POST /v1/batch` | هر پنج‌تا | هر آیتم `type` خودش را دارد |

روی پنج مسیر تک‌رویدادی، مسیر تعیین‌کننده است و فیلد `type` داخل بدنه نادیده گرفته می‌شود. اگر `{"type":"identify"}` را به `/v1/track` بفرستید، یک رویداد track ذخیره می‌شود. این عمدی است: در غیر این صورت یک بار اشتباه تایپ کردن در کد شما باعث می‌شد بار identify بی‌سروصدا به‌عنوان رویداد پذیرفته شود.

داخل `/v1/batch` قضیه برعکس است، چون مسیر یکی است و آیتم‌ها فرق دارند: `type` هر آیتم خوانده می‌شود و اگر ناشناخته باشد فقط همان آیتم با کد `unknown_type` رد می‌شود، نه کل دسته.

ویژگی‌های `identify` (که به آن‌ها «ویژگی» یا trait می‌گوییم) روی جدول رویدادها ذخیره نمی‌شوند. رویداد `identify` با ویژگی‌های خالی در جدول می‌نشیند و مقدار ویژگی‌ها فقط به پرونده کاربر می‌رود. اگر می‌خواهید در گزارش رویدادی ببینید که چه چیزی عوض شد، آن را به‌عنوان یک track جدا هم بفرستید.

```bash title="یک رویداد کامل"
curl -X POST https://in.segmentic.net/v1/track \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "message_id": "9f1c2b70-3a4d-4f2e-9a1b-0c7d8e5f6a21",
    "user_id": "u_88123",
    "event": "order_completed",
    "timestamp": "2026-08-07T09:14:22.310Z",
    "properties": {
      "order_id": "A-100294",
      "revenue": 2450000,
      "currency": "IRR",
      "item_count": 3,
      "payment_method": "gateway"
    },
    "context": {
      "locale": "fa-IR",
      "timezone": "Asia/Tehran",
      "library": { "name": "segmentic-js", "version": "1.0.0" }
    }
  }'
```

```json title="پاسخ"
{"status":"ok","accepted":1}
```

کلید نوشتن را می‌توانید با هدر `Authorization: Bearer wk_seg_...`، یا هدر `X-Segmentic-Key`، یا پارامتر `?write_key=` بدهید. سومی فقط برای `sendBeacon` و بیکن تصویری وجود دارد که نمی‌توانند هدر بگذارند.

اگر `message_id` نفرستید، سرور یکی می‌سازد و یک هشدار برمی‌گرداند:

```json title="پاسخ وقتی message_id نفرستاده‌اید"
{"status":"ok","accepted":1,"warnings":[{"code":"generated_message_id","field":"message_id","note":"no message_id sent; retries of this event cannot be de-duplicated"}]}
```

آن هشدار جدی است. `message_id` تنها چیزی است که تکرار را بی‌خطر می‌کند: SDK روی شبکه موبایل ایران دوباره می‌فرستد، و بدون شناسه ثابت، شمارش خرید مشتری بی‌صدا دو برابر می‌شود. حذف تکراری per-tenant است و دو تنانت می‌توانند از یک `message_id` استفاده کنند.

## قاعده نام‌گذاری {#naming}

این کل قاعده است، و کوتاه‌تر از چیزی است که انتظار دارید. نام رویداد `TrimSpace` می‌شود، از نرمال‌سازی فارسی می‌گذرد (بخش [متن فارسی](/docs/events#persian-text))، و بعد فقط دو چیز بررسی می‌شود:

| قاعده | حد | کد رد |
|---|---|---|
| طول بعد از نرمال‌سازی | ۱۲۸ بایت | `event_name_too_long` |
| کاراکتر کنترلی یونیکد | هیچ‌کدام مجاز نیست | `event_name_invalid_chars` |
| وجود نام روی track | اجباری | `missing_event_name` |

همین. **بدون کوچک‌کردن حروف، بدون snake_case کردن، بدون هیچ فیلتری روی کاراکترها.** یعنی فاصله، اسلش، علامت سؤال، حروف فارسی، ایموجی، حروف بزرگ و کوچک قاطی، و یک آدرس کامل، همه نام‌های قانونی رویداد هستند.

دو نتیجه که آدم‌ها با آن غافلگیر می‌شوند:

- `Order Completed` و `order_completed` و `order completed` سه رویداد جدا هستند، برای همیشه. هیچ‌چیزی آن‌ها را بعدا یکی نمی‌کند.
- حد ۱۲۸ **بایت** است، نه ۱۲۸ حرف. هر حرف فارسی در UTF-8 دو بایت است، پس یک نام فارسی حداکثر حدود ۶۴ حرف می‌تواند باشد.

قاعده‌ای که پیشنهاد می‌کنیم و بقیه مستندات با آن نوشته شده: حروف کوچک انگلیسی، `snake_case`، فعل در زمان گذشته، شیء قبل از فعل. `order_completed` نه `completeOrder`، `product_viewed` نه `View Product`. دلیلش زیبایی نیست: نام رویداد در پنل، در سگمنت‌ساز، در تریگر سناریو و در خروجی CSV عینا همان رشته‌ای است که فرستاده‌اید، و یک فهرست با سه شکل نوشتار قاطی، فهرستی است که کسی نمی‌تواند از آن انتخاب کند.

سرور هیچ محدودیتی روی **تعداد** نام‌های متمایز شما نمی‌گذارد. این را جدی بگیرید، چون بخش بعد درباره همان است.

اگر نام‌ها در یکپارچه‌سازی شما از قبل چیز دیگری است، `chk_out_v2` یا `SUB_RENEW`، برای خوانا شدن پنل لازم نیست چیزی را که کدتان می‌فرستد عوض کنید. پنل برای هر رویداد یک نام نمایشی نگه می‌دارد که فقط از «مدیریت داده»، در دستهٔ «رویدادها» گذاشته می‌شود. فقط نام نمایشی است: آنچه SDK می‌فرستد، آنچه تعریف یک سگمنت به آن ارجاع می‌دهد و آنچه در خروجی می‌آید هیچ‌کدام عوض نمی‌شوند، پس تغییر نام نمی‌تواند سگمنت یا قیف ذخیره‌شده‌ای را خراب کند. گذاشتنش به مجوز `settings.write` نیاز دارد.

## وقتی نام رویداد یک آدرس است {#url-shaped-names}

نام رویداد را از یک متغیر نسازید. این جمله انتزاعی به نظر می‌رسد تا وقتی که ببینید چطور اتفاق می‌افتد.

متد `page(name)` در SDK وب، رویداد را دقیقا به همان رشته‌ای نام‌گذاری می‌کند که به آن داده‌اید. یکی از مشتری‌ها به آن `pathname + search` را داد. نتیجه: نام رویداد کل آدرس شد، و هر کد معرف و هر شناسه پروفایل یک نوع رویداد جدید ساخت. عددهای واقعی آن حساب:

- ۱۶۹ نام متمایز آدرس‌شکل روی ۵۶۱۸ سطر.
- فهرست رویدادها در یک روز به ۱۶۸ نام رسید: ۱۵۳ تای آن آدرس بود و ۱۵ تا رویداد واقعی.
- «صفحه بازی‌ها را باز کرد» بین ۴۶ نام پخش شده بود: `/games/` و `/games/?ref=GPE9UHTV` و `/games/?utm_source=ecrm&utm_medium=inapp_banner` و `/u/69235/` و بقیه.
- همه آن ۱۶۹ نام، وقتی به مسیر تبدیل شوند، ۲۳ تا می‌شوند.

چرا این فقط زشت نیست بلکه داده را از دست می‌دهد: فهرست رویدادها (`GET /v1/schema/events`) حداکثر ۵۰۰ نام برمی‌گرداند، مرتب‌شده بر اساس حجم نزولی، فقط از ۹۰ روز اخیر. تنانتی که در این وضعیت است، نام‌های واقعی‌اش از ته فهرست می‌افتند بیرون. بازاریاب نمی‌تواند سگمنت «خرید کرد» را بسازد چون نام رویدادش در فهرست نیست.

راه درست: صفحه را به نام **مسیر** بفرستید نه به نام آدرس، و آدرس را همان‌جا که جایش هست بگذارید. اگر `autoContext` روشن باشد، SDK وب خودش `context.page.url` و `context.page.path` و `context.page.search` را روی هر پیام می‌گذارد و کلکتور دو تای اول را در ستون‌های `page_url` و `page_path` ذخیره می‌کند. پس آدرس دقیق از دست نمی‌رود.

```js title="نام مسیر، نه نام آدرس"
import { init, page } from "@segmentic/web";

init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
  // Off, because the automatic page view fires on init and would land
  // beside the route-named one below.
  autoPageView: false,
});

function routeNameOf(pathname) {
  return pathname
    .replace(/\/$/, "")
    .replace(/\/u\/\d+/g, "/u/:id")
    .replace(/\/(\d+)(?=\/|$)/g, "/:id") || "/";
}

// On the first load, and again on every client-side navigation.
page(routeNameOf(location.pathname));
```

یک نکته درباره `autoPageView` که تایپ خود SDK آن را جور دیگری می‌گوید. توضیح تایپ می‌گوید «یک بازدید صفحه روی راه‌اندازی و روی جابه‌جایی در تاریخچه». کد فقط روی راه‌اندازی می‌فرستد. تنها شنونده‌هایی که SDK وب نصب می‌کند `visibilitychange` و `pagehide` و `online` هستند و هیچ‌جای سورس هیچ `popstate` یا وصله‌ای روی `pushState` وجود ندارد. یعنی **یک اپ تک‌صفحه‌ای باید خودش روی هر جابه‌جایی `page()` را صدا بزند**، وگرنه کل نشست کاربر یک بازدید صفحه ثبت می‌کند.

و اگر همین حالا در این وضعیت هستید: اسکریپت `deploy/scripts/rename-url-event-names.sh` روی برنچ `chore/rename-the-url-shaped-event-names` این کار را برای سطرهای موجود می‌کند. رشته پرس‌وجو را حذف می‌کند، اسلش انتهایی را حذف می‌کند، چند مسیر پویای نام‌برده را جایگزین می‌کند و بعد هر بخش عددی باقی‌مانده را به `:id` تبدیل می‌کند. به‌صورت پیش‌فرض فقط گزارش می‌دهد و با `--apply` اجرا می‌شود.

> [!danger]
> این برنچ در `main` مرج نشده است، پس اسکریپت روی نصب استاندارد در دسترس نیست. مهم‌تر: کاری که می‌کند یک `ALTER TABLE ... UPDATE` است و برگشت ندارد، چون مقدار قبلی جایی نگه داشته نمی‌شود. خود اسکریپت پیش از `--apply` می‌شمارد که چند سطر `page_url` خالی دارند و اگر حتی یکی باشد اجرا نمی‌شود، چون برای آن سطرها آدرس فقط در نام زندگی می‌کند.

## انتخاب ویژگی‌ها {#choosing-properties}

روی رویداد آن چیزی را بگذارید که در **همان لحظه** درست بود. مبلغی که پرداخت شد، کدی که تخفیف داد، دسته‌ای که محصول در آن بود. اگر بعدا قیمت محصول عوض شود، رویداد دیروز هنوز قیمت دیروز را نشان می‌دهد، و این تنها راهی است که «درآمد ماه گذشته» معنی درستی داشته باشد.

سه چیز را روی رویداد نگذارید:

- چیزی که کلاینت خودش می‌فرستد. `context` همین حالا دستگاه، سیستم‌عامل، نسخه اپ، صفحه، کمپین، زبان و منطقه زمانی را می‌آورد. ویژگی `os_name` روی رویداد فقط یک نسخه دوم و بدتر از ستونی است که وجود دارد.
- توکن، رمز و شماره کارت. ویژگی‌ها در صفحه تایم‌لاین کاربر عینا به تیم پشتیبانی نشان داده می‌شوند و در خروجی CSV هم می‌آیند.
- چیزی که فقط یک مقدار برای هر آدم دارد و تغییر می‌کند، مثل «سطح باشگاه مشتریان». آن ویژگی پرونده است.

کلید ویژگی هم نرمال می‌شود، و بدانید چطور، چون کلید چیزی است که بعدا در فیلتر تایپ می‌کنید:

| ورودی | کلید ذخیره‌شده |
|---|---|
| `"  spaced  key "` | `spaced_key` |
| `"dotted.key"` | `dotted_key` |
| `"dashed-key"` | `dashed_key` |
| `"multi   space"` | `multi_space` |
| `"_leading_"` | `leading` |
| `""` یا `"   "` | حذف می‌شود |

یعنی: فاصله و نقطه و خط تیره به یک `_` تبدیل می‌شوند، `_` ابتدا و انتها حذف می‌شود، کاراکتر کنترلی دور ریخته می‌شود و کلید در ۱۲۸ بایت بریده می‌شود. اما کلیدها **کوچک نمی‌شوند و نرمال‌سازی فارسی روی آن‌ها اجرا نمی‌شود**. `Price` و `price` دو ویژگی جدا هستند.

## یک ویژگی چه چیزی می‌تواند نگه دارد {#property-types}

ClickHouse نوع `Map` همگن دارد، پس هر ویژگی در یکی از دو نقشه یا هر دو نوشته می‌شود: `props_str` برای متن و `props_num` برای عدد. جدول کامل:

| مقدار در JSON | در `props_str` | در `props_num` |
|---|---|---|
| `null` | نوشته نمی‌شود | نوشته نمی‌شود |
| رشته | نرمال‌سازی فارسی، بریده در ۸۱۹۲ بایت | نوشته نمی‌شود |
| `true` یا `false` | `"true"` یا `"false"` | `1` یا `0` |
| عدد | شکل متنی عدد | خود عدد |
| آرایه یا شیء | همان JSON به‌صورت رشته، بریده در ۸۱۹۲ بایت | نوشته نمی‌شود |
| چیزی که JSON نمی‌شود | نوشته نمی‌شود | نوشته نمی‌شود، به‌علاوه هشدار `unserialisable_property` |

`null` یعنی «تنظیم نشده»، و اگر آن را `""` ذخیره می‌کردیم فیلتر «تنظیم نشده» غلط جواب می‌داد.

عدد صحیح، صفر اضافه نمی‌گیرد: `1234` به‌صورت رشته `"1234"` ذخیره می‌شود نه `"1234.0"`. بدون این، شماره سفارشی که به‌صورت عدد JSON می‌آید، دیگر به سیستم خود مشتری join نمی‌شد.

```json title="آنچه می‌فرستید"
{
  "type": "track",
  "user_id": "u_88123",
  "event": "order_completed",
  "properties": {
    "str": "hello",
    "num": 42.5,
    "int_like": 1234,
    "bool_t": true,
    "bool_f": false,
    "nil": null,
    "arr": [1, 2],
    "obj": { "a": 1 }
  }
}
```

```text title="آنچه ذخیره می‌شود"
props_str = { str: "hello", num: "42.5", int_like: "1234",
              bool_t: "true", bool_f: "false",
              arr: "[1,2]", obj: "{\"a\":1}" }
props_num = { num: 42.5, int_like: 1234, bool_t: 1, bool_f: 0 }
```

`nil` در هیچ‌کدام نیست. `arr` و `obj` فقط متن‌اند، ولی با توابع JSON خود ClickHouse هنوز قابل فیلتر شدن‌اند. هیچ‌چیزی تخت نمی‌شود و هیچ عمقی از دست نمی‌رود.

مهم‌ترین نتیجه این جدول: **رشته عددشکل هرگز عدد نمی‌شود.** اگر `"price": "2450000"` بفرستید، `props_num` هیچ‌وقت `price` نمی‌گیرد و فیلتر «بیشتر از» روی آن هرگز چیزی پیدا نمی‌کند. این عمدی است، چون تجزیه‌کردن `"01234"` صفر ابتدایی کد پستی را دور می‌ریزد. اگر منظورتان عدد است، در JSON عدد بفرستید. همین قاعده برای رقم فارسی هم هست: `"۲۴۵۰۰۰۰"` یک رشته است و رشته می‌ماند.

هیچ ثبت نوعی وجود ندارد. هر رویداد مستقل نرمال می‌شود و هیچ‌جای مسیر ورودی حافظه‌ای از این ندارد که این کلید دفعه قبل چه نوعی بود. پس فرستادن `price` به‌صورت عدد و بعد به‌صورت متن، نه خطا می‌دهد نه هشدار. فقط بعضی سطرها در `props_num` هستند و بعضی نیستند.

## حدها و اینکه وقتی رد می‌شوید چه می‌شود {#property-limits}

| چیز | حد | وقتی رد شود |
|---|---|---|
| ویژگی در هر رویداد | ۲۵۶ | ۲۵۶ تای اول نگه داشته می‌شود، بقیه دور ریخته، هشدار `too_many_properties` |
| طول کلید ویژگی | ۱۲۸ بایت | بریده می‌شود |
| طول مقدار ویژگی | ۸۱۹۲ بایت | بریده می‌شود، روی مرز حرف UTF-8 |
| ویژگی پرونده در هر پیام | ۲۵۶ | حلقه متوقف می‌شود، هشدار `too_many_traits` |
| آیتم در هر batch | ۵۰۰ | کل درخواست با `batch_too_large` رد می‌شود |
| اندازه بدنه | ۵ مگابایت | کد ۴۱۳ |
| طول نام رویداد | ۱۲۸ بایت | رد با `event_name_too_long` |
| طول `user_id` و `anonymous_id` و `message_id` | ۲۵۶ بایت | رد با `id_too_long` |
| طول `session_id` | ۲۵۶ بایت | بریده می‌شود، رد نمی‌شود |
| طول `previous_id` روی alias | حدی ندارد | هیچ بررسی طولی روی آن نیست؛ فقط سقف ۵ مگابایتی بدنه محدودش می‌کند |
| آدرس و مسیر و ارجاع صفحه | ۲۰۴۸ بایت | بریده می‌شود |
| عمق تودرتویی | حدی ندارد | آرایه و شیء یکجا به JSON تبدیل می‌شوند |

دو ریزه‌کاری که فقط وقتی به آن‌ها برمی‌خورید که دیر شده است:

**«۲۵۶ تای اول» ترتیب مشخصی ندارد.** پیمایش نقشه در Go تصادفی است، پس اگر ۳۰۰ ویژگی بفرستید، اینکه کدام ۴۴ تا حذف می‌شوند در هر رویداد فرق می‌کند. نتیجه یک ستون نیمه‌پر است که در گزارش شبیه داده کم‌کیفیت به نظر می‌رسد نه شبیه یک باگ. متن هشدار عدد دقیق را می‌گوید: `300 properties sent, keeping 256`.

**یک ویژگی با مقدار `null` یکی از ۲۵۶ جا را می‌گیرد ولی چیزی ذخیره نمی‌کند.** برای ویژگی‌های پرونده این‌طور نیست: آنجا مقدار خالی قبل از شمارش رد می‌شود.

بریدن هرگز وسط یک حرف اتفاق نمی‌افتد. اگر می‌افتاد، رشته UTF-8 نامعتبر می‌شد، ClickHouse کل دسته را رد می‌کرد، و یک رشته خراب کل یک پارتیشن را متوقف می‌کرد.

## درآمد، و اینکه چطور بی‌اجازه جمع می‌شود {#revenue}

اینجا یک رفتار هست که باید قبل از طراحی رویدادهایتان بدانید: **درآمد از هر رویدادی استخراج می‌شود، نه فقط از سفارش.**

استخراج به این ترتیب کار می‌کند، اولین مقدار غیرصفر برنده است:

1. `props_num["revenue"]`
2. `props_num["total"]`
3. `props_num["value"]`
4. و اگر هیچ‌کدام نبود: `props_num["price"] × props_num["quantity"]`، با `quantity` پیش‌فرض ۱ وقتی نیامده یا مثبت نیست.

ارز از `props_str["currency"]` می‌آید، بزرگ می‌شود و در ۸ بایت بریده می‌شود؛ پیش‌فرض `IRR` است. `IRT` هم یک مقدار شناخته‌شده است، اما **هیچ تبدیلی بین ریال و تومان انجام نمی‌شود**. هر عددی که بفرستید در همان واحدی می‌ماند که گفته‌اید و جمع‌ها فرض می‌کنند شما ثابت مانده‌اید.

حالا قسمت گران: پرونده کاربر درآمد را از هر رویدادی که آن را حمل کند جمع می‌کند.

| نام رویداد | اثر روی پرونده |
|---|---|
| `order_completed` | `total_revenue` زیاد می‌شود، `order_count` یکی بالا می‌رود، `last_order_at` جلو می‌رود |
| `order_refunded` و `order_cancelled` | قدرمطلق مبلغ از `total_revenue` کم می‌شود (کف صفر) و `order_count` یکی پایین می‌آید (کف صفر) |
| هر نام دیگری | مبلغ به `total_revenue` اضافه می‌شود |

پس یک رویداد `product_added_to_cart` که `price` و `quantity` دارد، همین حالا به ارزش طول عمر آن کاربر اضافه می‌کند، بدون اینکه کسی سفارشی ثبت کرده باشد. سگمنت «مشتری وی‌آی‌پی» که روی `total_revenue` ساخته شده، پر می‌شود از آدم‌هایی که فقط سبد پر کرده‌اند.

> [!warn]
> کلیدهای `revenue` و `total` و `value` و همچنین جفت `price` با `quantity` را فقط روی رویدادی بگذارید که واقعا پول جابه‌جا شده است. برای بقیه اسم دیگری بگذارید: `unit_price`، `cart_total`، `estimated_value`. این کلیدها رزرو شده‌اند و کد به آن‌ها معنی می‌دهد.

`order_id` و `product_id` هم در فهرست کلیدهای رزرو اعلام شده‌اند، ولی هیچ کدی آن‌ها را نمی‌خواند. ویژگی معمولی‌اند.

## نام‌هایی که پلتفرم به آن‌ها معنی می‌دهد {#reserved-names}

پلتفرم یازده نام استاندارد فروشگاهی را اعلام می‌کند. فقط سه‌تای آن‌ها رفتار دارند (بخش بالا) و بقیه نام معمولی‌اند که فقط در پنل برچسب فارسی می‌گیرند:

`product_viewed` و `product_added_to_cart` و `product_removed_from_cart` و `cart_viewed` و `checkout_started` و `order_completed` و `order_refunded` و `order_cancelled` و `searched` و `signed_up` و `signed_in`.

«فهرست پیش‌فرض» را بیش از آنچه هست حساب نکنید. سگمنت‌ساز تا وقتی اسکیمای واقعی تنانت بارگذاری نشده، ده نام ثابت را نشان می‌دهد و بس: `order_completed` و `product_viewed` و `product_added_to_cart` و `checkout_started` و `order_refunded` و `searched` و `app_opened` و `signed_up` و `message_opened` و `message_clicked`. بقیه نام‌های استاندارد، از جمله `cart_viewed` و `product_removed_from_cart` و `signed_in` و `order_cancelled`، در آن فهرست موقت نیستند. به‌محض اینکه اسکیما برسد، فهرست از رویدادهای واقعی خودتان ساخته می‌شود و این ده‌تا فقط برچسب می‌دهند.

چهار نام را خود سرور می‌سازد و شما نمی‌توانید عوضشان کنید: `page_viewed` و `screen_viewed` وقتی `page` یا `screen` بدون نام بیاید، و `identify` و `alias` که همیشه ثابت‌اند.

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

| نام | چه کسی می‌نویسد |
|---|---|
| `message_sent` | فرستنده پیام، روی ارسال موفق |
| `message_failed` | فرستنده پیام، روی شکست |
| `message_withheld` | فرستنده پیام، فقط برای گروه کنترل |
| `message_opened` | نقطه ردیابی باز شدن پیام |

`message_withheld` توضیح خودش را می‌خواهد: کسی برای کمپین انتخاب شده و عمدا برایش پیامی نرفته است. سطر نوشته می‌شود تا سنجش اثر یک خط پایه داشته باشد، چون بدون سطری که بگوید «این آدم را انتخاب کردیم و ساکت ماندیم»، چیزی برای مقایسه با گروه درمان وجود ندارد. هر نوع دیگری از سرکوب پیام عمدا در جریان نمی‌آید، چون ثبت‌کردنشان به‌عنوان ارسال، دسترسی هر کمپین را دقیقا به اندازه آدم‌هایی که به آن‌ها نرسیده متورم می‌کند.

نام‌های `message_delivered` و `message_bounced` و `unsubscribed` به‌عنوان ثابت اعلام شده‌اند و هیچ جای دیگر بک‌اند از آن‌ها استفاده نمی‌کند: نه ورکری آن‌ها را می‌نویسد و نه چیزی آن‌ها را می‌خواند، پس گزارشی که روی `message_delivered` بسازید خالی برمی‌گردد. با این حال خود پنل رویداد تحویل می‌خواهد. قالب داشبورد «عملکرد پیام‌رسانی» و متریک‌های پایه «نرخ تحویل پیام» و «نرخ بازشدن پیام» و «نرخ کلیک پیام» هرکدام به یک رویداد تحویل نیاز دارند که از اسکیمای رویداد خودتان به آن‌ها وصل شود، و تا اسمی ندهید فعال‌سازی جلو نمی‌رود، پس اگر خودتان رویداد تحویل نفرستید، آن داشبورد و آن سه متریک هرگز ساخته نمی‌شوند.

آنچه کم است رویداد است، نه داده. گزارش کمپین شمار پیام‌های تحویل‌شده خودش را از رسیدهایی می‌گیرد که مسیر ارسال پس می‌فرستد، سرویس تحویل‌پذیری برگشتی‌ها را از مسیر بازگشت SMTP می‌خواند و روی فهرست مسدود می‌نویسد، و لغو اشتراک روی پرونده آن آدم در مرکز تنظیمات می‌نشیند و در ارسال بعدی به‌عنوان دلیل سرکوب برمی‌گردد. هیچ‌کدام این‌ها به‌شکل سطر وارد جریان رویداد شما نمی‌شود.

ورکر انتساب دقیقا چهار نام را می‌شناسد، `message_sent` و `message_withheld` و `message_opened` و `message_clicked`، و هر رویداد دیگری را که به دستش برسد نادیده می‌گیرد. `message_clicked` را هم ورکر نمی‌نویسد: [SDK وب](/docs/sdk-web) آن را وقتی می‌فرستد که بازدیدکننده روی لینکی با `sg_mid` وارد سایت شود، یک بار، تا وقتی شناسه پیام دیگری جایش را بگیرد. مقایسه با همان یک شناسه داخل حافظه است نه با تاریخچه‌ای از شناسه‌ها، پس بازدیدکننده‌ای که روی یک پیام بیاید، بعد روی پیام دوم، و بعد دوباره روی همان پیام اول، کلیک پیام اول را دو بار گزارش می‌کند.

> [!danger]
> شش نام `app_installed` و `app_opened` و `app_updated` و `app_removed` و `session_started` و `session_ended` در کد به‌عنوان «رویدادهای چرخه عمر که SDKها خودکار می‌فرستند» اعلام شده‌اند. **هیچ SDKای آن‌ها را نمی‌فرستد.** نه SDK وب، نه اندروید، نه iOS. جست‌وجو در هر سه، صفر فرستنده پیدا می‌کند. `app_opened` در فهرست پیش‌فرض رویدادهای پنل هم هست و برچسب فارسی دارد، که باعث می‌شود خودکار به نظر برسد. اگر این رویدادها را می‌خواهید، خودتان باید بفرستیدشان.

## ویژگی رویداد یا ویژگی پرونده {#property-or-trait}

این جایی است که بیشترین اشتباه اتفاق می‌افتد، و اشتباهش تا ماه‌ها بعد خودش را نشان نمی‌دهد.

| | ویژگی رویداد | ویژگی پرونده |
|---|---|---|
| چطور فرستاده می‌شود | `properties` روی track یا page یا screen | `traits` روی identify |
| کجا می‌نشیند | روی همان یک سطر رویداد | روی پرونده کاربر، یک مقدار برای هر آدم |
| تاریخچه دارد | بله، هر رویداد نسخه خودش را نگه می‌دارد | خیر، آخرین مقدار جای قبلی را می‌گیرد |
| با کاربر مهمان کار می‌کند | بله | خیر، تا وقتی `user_id` نداشته باشید پرونده‌ای ساخته نمی‌شود |
| برای چه فیلتری خوب است | «کسی که در ۳۰ روز گذشته سفارش بالای ۵۰۰ هزار داشت» | «کسی که الان سطح طلایی است» |

مثال دقیق: `city` به‌عنوان ویژگی روی `order_completed` یعنی «این سفارش به تهران رفت». همان `city` به‌عنوان ویژگی پرونده یعنی «این آدم الان در تهران زندگی می‌کند». اولی هرگز عوض نمی‌شود، دومی با هر identify عوض می‌شود و مقدار قبلی برای همیشه می‌رود.

سه چیز از این تفاوت درمی‌آید که در ساخت سگمنت به آن‌ها می‌خورید:

**ویژگی‌های پرونده شناخته‌شده از نقشه ویژگی‌ها بیرون کشیده می‌شوند.** `email` و `phone` و `first_name` و `last_name` و `gender` و `birthday` و `national_id` و `city` و `region` و `country` و `language` و `timezone` و `push_opt_in` و `email_opt_in` و `sms_opt_in` به ستون‌های واقعی پرونده می‌روند و در نقشه آزاد `traits` نمی‌مانند. پس در پاسخ `GET /v1/schema/traits` دیده نمی‌شوند. ویژگی `email` شما گم نشده است، فقط ستون است.

**فیلتر عددی روی ویژگی پرونده، کسی را که آن ویژگی را ندارد انتخاب نمی‌کند.** کامپایلر سگمنت شرط را با `mapContains` می‌پیچد. جهت خطا عمدی انتخاب شده است: مخاطبی که بی‌صدا خالی است یعنی کمپینی که نمی‌رود و کسی متوجه می‌شود؛ مخاطبی که بی‌صدا همه است یعنی کمپینی که برای همه رفته و پس گرفته نمی‌شود.

**فیلتر عددی روی ویژگی رویداد این محافظ را ندارد.** فیلتر روی `props_num` بدون `mapContains` کامپایل می‌شود، و نقشه در ClickHouse برای کلید غایب مقدار صفر می‌دهد. یعنی شرط «قیمت کمتر از ۱۰۰۰۰» رویدادهایی را هم می‌گیرد که اصلا `price` نفرستاده‌اند. اگر ویژگی را روی همه رویدادهای آن نام نمی‌فرستید، شرط «تنظیم شده» را هم کنارش بگذارید.

بررسی «تنظیم شده» برای ویژگی رویداد روی `props_str` انجام می‌شود، که مشکلی ندارد: هر مقدار عددی همیشه در هر دو نقشه نوشته می‌شود، پس `props_str` مجموعه بزرگ‌تر است.

## متن فارسی {#persian-text}

هر رشته‌ای که کاربر می‌فرستد پیش از ذخیره از `Normalize` می‌گذرد. این‌ها جاهایی است که اجرا می‌شود:

- نام رویداد
- مقدار هر ویژگی رشته‌ای
- مقدار هر ویژگی پرونده سفارشی
- `context.location.country` و `.region` و `.city`
- `context.page.title`

و این‌ها جاهایی است که اجرا **نمی‌شود**: کلید ویژگی، کلید ویژگی پرونده، `user_id`، `anonymous_id`، `message_id`، `session_id`، آدرس و مسیر و ارجاع صفحه، مقدارهای UTM. برای `email` فقط حروف کوچک می‌شود، `phone` جدا تجزیه می‌شود و `national_id` فقط رقم‌هایش به لاتین تبدیل می‌شود.

آنچه انجام می‌دهد:

| از | به |
|---|---|
| یای عربی `ي`، الف مقصوره `ى`، `ے` | یای فارسی `ی` |
| کاف عربی `ك`، `ڪ` | کاف فارسی `ک` |
| `ة`، `ۀ` | `ه` |
| `ؤ` | `و` |
| `أ`، `إ`، `ٱ` | `ا` |

به‌علاوه اعراب و کشیده و ZWJ به‌کلی حذف می‌شوند، و هر دنباله فاصله (شامل فاصله بدون‌شکست، فاصله‌های یونیکد، و BOM) به یک فاصله ساده تبدیل و از دو سر بریده می‌شود. کپی از Word و از ویرایشگرهای راست‌به‌چپ بیشتر این‌ها را با خودش می‌آورد.

آنچه عمدا دست‌نخورده می‌ماند:

- نیم‌فاصله. `می‌رود` برای خواننده کلمه دیگری است غیر از `میرود`.
- بزرگی و کوچکی حروف لاتین. `Digikala` همان `Digikala` می‌ماند.
- رقم فارسی. `۱۴۰۵` همان `۱۴۰۵` می‌ماند و به لاتین تبدیل نمی‌شود.
- فاصله با عرض صفر، الف با کلاه، ی با همزه، و همزه تنها.

نتیجه عملی: `تهراني` که با صفحه‌کلید عربی تایپ شده و `تهرانی` که با صفحه‌کلید فارسی تایپ شده، یک مقدار می‌شوند و در یک سگمنت می‌افتند. اما `Tehran` و `tehran` یکی نمی‌شوند، و `۱۲۳` هرگز `123` نمی‌شود.

## تلفن، کد ملی و جنسیت {#identity-traits}

چهار ویژگی پرونده رفتار مخصوص دارند، چون هویت آدم‌اند و ذخیره‌کردنشان به هر شکلی که رسیده، تضمین می‌کند برای یک انسان دو پرونده داشته باشید.

**`phone`** با `ParsePhone` تجزیه می‌شود و به شکل `E.164` ذخیره می‌شود، به‌علاوه یک ویژگی مشتق به نام `phone_operator`. الگوریتم: رقم‌های فارسی و عربی به لاتین، بعد فقط رقم‌ها و یک `+` ابتدایی نگه داشته می‌شوند، بعد پیشوند کشور برداشته می‌شود، و آنچه می‌ماند باید دقیقا ده رقم باشد که با ۹ شروع می‌شود.

```text title="شکل‌هایی که همه یک نتیجه می‌دهند"
09123456789     9123456789      +989123456789    00989123456789
989123456789    0912 345 6789   0912-345-6789    (0912) 345 6789
۰۹۱۲۳۴۵۶۷۸۹    ٠٩١٢٣٤٥٦٧٨٩

نتیجه:  phone = "+989123456789"   phone_operator = "mci"
```

```text title="شکل‌هایی که رد می‌شوند"
""   "abc"   "0812345678"   "091234567"   "091234567890"
"+981234567890"   "12345"   "+1234567890"   "0000000000"
```

اگر تجزیه شکست بخورد، مقدار خام همان‌طور که آمده ذخیره می‌شود و هشدار `invalid_phone` برمی‌گردد؛ `phone_operator` نوشته نمی‌شود. دور نمی‌ریزیمش، چون یک شماره بدشکل هنوز تنها راه تماس با آن آدم است.

اپراتور از چهار رقم اول شکل داخلی شماره درمی‌آید، یعنی همان `09XX`:

| اپراتور | پیشوندها |
|---|---|
| `mci` (همراه اول) | ۰۹۱۰ تا ۰۹۱۹، ۰۹۹۰ تا ۰۹۹۷، ۰۹۹۹ |
| `irancell` (ایرانسل) | ۰۹۰۰ تا ۰۹۰۵، ۰۹۳۰، ۰۹۳۳، ۰۹۳۵ تا ۰۹۳۹، ۰۹۴۱ |
| `rightel` (رایتل) | ۰۹۲۰ تا ۰۹۲۳ |
| `shatel` (شاتل موبایل) | ۰۹۹۸ |
| `samantel` (سامانتل) | ۰۹۳۱ |
| `unknown` | هر چیز دیگری، مثلا ۰۹۰۶ یا ۰۹۳۲ یا ۰۹۳۴ |

**`national_id`** با الگوریتم رقم کنترلی مبنای یازده بررسی می‌شود. طول باید بین ۸ تا ۱۰ باشد و کوتاه‌تر از ۱۰ از چپ با صفر پر می‌شود، چون صفر ابتدایی مرتب در فایل اکسل گم می‌شود. این پرکردن فقط برای حساب رقم کنترلی است. چیزی که ذخیره می‌شود همان مقداری است که فرستادید و فقط رقم‌هایش به لاتین تبدیل می‌شود، پس `12345679` هشت‌کاراکتری می‌ماند. کد ملی با رقم‌های یکسان مثل `1111111111` رد می‌شود، حتی وقتی از رقم کنترلی رد شود. اگر نامعتبر باشد، ویژگی **کلا حذف می‌شود** و هشدار `invalid_national_id` برمی‌گردد. تلفن نامعتبر همان‌طور که فرستادید نگه داشته می‌شود، ولی کد ملی نامعتبر اصلا نگه داشته نمی‌شود، چون مشتری‌های سازمانی CRM خودشان را روی این کلید می‌بندند و یک مقدار غلط، یک پرونده تقلبی می‌سازد.

**`email`** فقط `TrimSpace` و کوچک می‌شود. هیچ بررسی قالبی در ورودی انجام نمی‌شود.

**`gender`** به دقیقا یکی از سه مقدار `male` یا `female` یا `other` نگاشت می‌شود. `male` از `m` و `male` و `man` و `مرد` و `اقا` و `پسر` می‌آید؛ `female` از `f` و `female` و `woman` و `زن` و `خانم` و `دختر`؛ هر چیز دیگری `other` می‌شود. مقایسه از تابع `Fold` می‌گذرد، پس `آقا` هم به `male` می‌رسد.

بقیه ویژگی‌های پرونده، از جمله `city` و `first_name` و `birthday` و `language` و کلیدهای رضایت، فقط نرمال‌سازی فارسی و بریدن می‌گیرند.

## محافظ کاردینالیتی {#cardinality}

سه چیز را باید از هم جدا نگه داشت، چون فقط یکی از آن‌ها واقعا محافظ دارد.

**آنچه محافظت می‌شود: برچسب متریک‌ها.** وقتی رویدادی رد می‌شود، آنچه به‌عنوان دلیل شمرده می‌شود یکی از کدهای ثابت است، نه متن کامل خطا. متن کامل خطا نوع رویداد یا زمان فرستاده‌شده توسط شما را در خودش دارد، و پیش‌تر همان متن مستقیم برچسب Prometheus می‌شد: یعنی هرکسی که یک کلید نوشتن داشت می‌توانست با فرستادن یک نام تصادفی در هر درخواست، به ازای هر درخواست یک سری زمانی جدید بسازد. این حافظه بی‌کران است و اول از همه سیستم پایش را از کار می‌اندازد، یعنی دقیقا همان چیزی که برای فهمیدن ماجرا لازم دارید. حالا ۳۰۰ نوع نامعتبر متمایز در یک دسته، به یک برچسب جمع می‌شوند. بدنه پاسخ همچنان متن کامل و مقدار مقصر را به شما می‌دهد.

**آنچه محافظت نمی‌شود: تعداد نام‌های رویداد شما.** هیچ شمارنده‌ای وجود ندارد و هیچ سقفی اعمال نمی‌شود. تنها اثری که می‌بینید این است که فهرست رویدادها در ۵۰۰ نام قطع می‌شود.

**آنچه محافظت نمی‌شود: تعداد مقدارهای متمایز یک ویژگی.** هیچ سقفی روی آن هم نیست. اگر شناسه جلسه یا شماره سفارش را به‌عنوان مقدار ویژگی بفرستید، این کار درست است و مشکلی ندارد. اگر همان را به‌عنوان **نام رویداد** بفرستید، بخش [نام آدرس‌شکل](/docs/events#url-shaped-names) را دوباره بخوانید.

## دیدن اینکه واقعا چه رسید {#what-arrived}

سه راه، و هر سه چیز متفاوتی به شما می‌گویند.

**بدنه پاسخ.** هشدارها همان لحظه برمی‌گردند. کل فهرست کدهای هشدار: `generated_message_id`، `timestamp_in_future`، `timestamp_too_old`، `too_many_properties`، `unserialisable_property`، `too_many_traits`، `invalid_phone`، `invalid_national_id`. در `/v1/batch` کلکتور وقتی از قبل ۵۰ هشدار جمع کرده باشد، دیگر هشداری اضافه نمی‌کند، و این بررسی را پیش از افزودن هشدارهای یک آیتم کامل انجام می‌دهد، پس پاسخ می‌تواند ۴۹ هشدار به‌علاوه هرچه آیتم بعدی ساخته را حمل کند. شمارش‌ها دقیق می‌مانند.

```json title="یک دسته که یک آیتمش بد بود"
{"status":"ok","accepted":2,"rejected":1,"errors":[{"index":1,"reason":"missing_event_name"}]}
```

یک آیتم بد کل دسته را غرق نمی‌کند. آیتم‌های رد شده با شماره‌شان در آرایه گزارش می‌شوند. توجه کنید که `accepted` شامل تکراری‌ها هم هست، تا SDK دست از تلاش دوباره بردارد.

**فهرست رویدادها.** `GET /v1/schema/events` روی `api.segmentic.net` با کلید API و دسترسی `event.read`:

```bash title="فهرست رویدادها"
curl -H "Authorization: Bearer sk_seg_..." \
  https://api.segmentic.net/v1/schema/events
```

```json title="پاسخ"
{
  "events": [
    { "name": "product_viewed", "volume": 88000000, "prop_keys": ["product_id","price"], "last_seen": "2026-08-06" },
    { "name": "order_completed", "volume": 4200000, "prop_keys": ["revenue","order_id"], "last_seen": "2026-08-07" }
  ]
}
```

`last_seen` مفیدترین ستون این جدول است: رویدادی با حجم بزرگ و آخرین مشاهده سه هفته پیش، یعنی یکپارچگی‌ای که شکسته است، و هیچ عدد دیگری این را نمی‌گوید چون حجم تا یک ماه بعد هنوز سالم به نظر می‌رسد. پنجره ۹۰ روز است، سقف ۵۰۰ نام مرتب بر اساس حجم، و `prop_keys` اتحاد کلیدهای دو نقشه است **بدون هیچ اطلاعاتی از نوع**. یعنی این endpoint نمی‌تواند به شما بگوید نوع یک ویژگی عوض شده است.

تازگی داده به چرخه نوشتن ingestor بند است: هر ۱۰۰۰۰ رویداد یا هر ۵ ثانیه، هرکدام زودتر رسید. کش و job جداگانه‌ای در کار نیست.

**دیباگر زنده.** در پنل، ضبط را روشن می‌کنید و ۳۰ دقیقه رویدادها را همان‌طور که می‌رسند می‌بینید، با هشدارهایشان. آخرین ۲۰۰ رویداد نگه داشته می‌شود. تا وقتی کسی دیباگر را باز نکرده باشد هیچ ضبطی انجام نمی‌شود.

> [!danger]
> دیباگر فقط مسیرهای تک‌رویدادی را ضبط می‌کند. `POST /v1/batch` هیچ‌چیزی به آن نمی‌دهد، و هر سه SDK دسته‌ای می‌فرستند. یعنی وقتی SDK را تازه نصب کرده‌اید و رویدادها هم دارند می‌رسند، دیباگر خالی می‌ماند و شما فکر می‌کنید کار نکرده است. برای تایید یک نصب SDK از صفحه «اتصال» در پنل استفاده کنید که فعالیت اپ را در ۲۴ ساعت گذشته می‌پرسد، یا موقتا با `POST /v1/track` یک رویداد تکی بفرستید.

> [!warn]
> جدول `segmentic.ingest_warnings` در اسکیما وجود دارد و توضیحش می‌گوید تاریخچه هشدارهای هر تنانت را نگه می‌دارد. **هیچ‌چیزی در آن نمی‌نویسد.** پس تاریخچه هشدار وجود ندارد: هشدار فقط در بدنه پاسخ و در دیباگر زنده دیده می‌شود. اگر می‌خواهید بدانید SDK اندروید شما از دیروز `user_id` نمی‌فرستد، باید خودتان پاسخ‌ها را لاگ کنید.

## تصمیم‌هایی که بعدا برگشت ندارند {#hard-to-reverse}

هر ردیف این جدول، چیزی است که یک بار روی داده نوشته می‌شود و بعد فقط می‌شود از آن به بعد را درست کرد.

| تصمیم | چرا برگشت ندارد | به‌جایش چه کنید |
|---|---|---|
| نام رویداد | تغییر نام یعنی `ALTER TABLE ... UPDATE` روی سطرهای موجود، که مقدار قبلی را نگه نمی‌دارد. اسکریپتش هم در `main` نیست | قبل از اولین ارسال، فهرست نام‌ها را روی کاغذ بنویسید. فهرست آماده در [فرهنگ‌نامه رویدادها](/docs/event-dictionary) هست |
| ویژگی‌ای که نفرستادید | هیچ راهی برای افزودن ویژگی به سطرهای نوشته‌شده وجود ندارد | ویژگی‌های زمینه‌ای را از روز اول بفرستید، حتی اگر هنوز به آن‌ها نیاز ندارید |
| عدد که به‌شکل رشته فرستاده شد | رشته عددشکل هرگز تجزیه نمی‌شود، و رویدادهای بعدی سطرهای قبلی را درست نمی‌کنند | در JSON عدد بفرستید. رقم فارسی هم رشته است |
| مهاجرت تاریخچه از `POST /v1/events` | آن مسیر یک پنجره ثابت ۳۰ روزه دارد، هرچه قدیمی‌تر باشد به لبه پنجره چسبانده می‌شود، پاسخ ۲۰۲ است و هیچ هشداری در بدنه برنمی‌گردد. یک سال سفارش به‌صورت یک روز غول‌آسا می‌نشیند | از مسیر واردکردن استفاده کنید که در حالت backfill رد می‌کند به‌جای اینکه جابه‌جا کند |
| ویژگی پرونده که نوعش عوض شد | فرستادن `"abc"` بعد از `428` نیمه متنی را عوض می‌کند ولی نیمه عددی روی `428` می‌ماند، و از مسیر ورودی راهی برای پاک‌کردنش هم نیست: مقدار رشته خالی پیش از رسیدن به پرونده دور ریخته می‌شود | نوع ویژگی پرونده را ثابت نگه دارید |
| رویداد اشتباهی که فرستاده شد | endpointی برای حذف یک رویداد وجود ندارد | فقط سیاست نگهداری و TTL چهارصدروزه خود جدول و پاک‌سازی شخص، سطر را برمی‌دارند |

دو ریزه‌کاری روی سطر مهاجرت. پنجره ۳۰ روزه فقط برای `POST /v1/events` است؛ کلکتور روی `in.segmentic.net` پنجره‌اش سیاست نگهداری خود تنانت است و اگر چیزی را بچسباند، هشدار `timestamp_too_old` را هم در بدنه پاسخ می‌دهد. و مسیر واردکردن (`POST /v1/import/events`) روی API عمومی ثبت نشده است: فقط از پنل در دسترس است، پس مهاجرت تاریخچه کاری است که یک نفر با مرورگر انجام می‌دهد نه یک اسکریپت با کلید مدیریتی.

و یک محدودیت که سیاست شما نیست، سقف پلتفرم است: جدول رویدادها یک TTL چهارصدروزه دارد. هیچ رویدادی بیشتر از ۴۰۰ روز نمی‌ماند، هر سیاست نگهداری‌ای که تنظیم کرده باشید. اگر به نگهداری بلندتر نیاز دارید، خروجی بگیرید.

قدم بعد: [فرهنگ‌نامه رویدادها](/docs/event-dictionary) فهرست آماده برای شش نوع کسب‌وکار دارد، و [هویت](/docs/identity) می‌گوید `user_id` و `anonymous_id` چطور به هم وصل می‌شوند.

---

# فرهنگ‌نامهٔ رویدادها برای هر نوع کسب‌وکار

> فهرست آمادهٔ رویدادها و ویژگی‌هایشان برای فروشگاه اینترنتی، سوپراپ، فین‌تک، سفر، آموزش و اشتراک محتوا. کپی کنید و شروع کنید.
> https://segmentic.net/docs/event-dictionary

این صفحه برای کپی‌کردن نوشته شده است. شش نوع کسب‌وکار، و برای هرکدام فهرستی از رویدادها با ویژگی‌هایشان که می‌توانید همان‌طور که هست بردارید و کار را شروع کنید. قاعده‌های پشت این انتخاب‌ها در [تعریف رویداد](/docs/events) است؛ اگر هنوز آن را نخوانده‌اید، دست‌کم بخش [درآمد](/docs/events#revenue) را بخوانید، چون تنها جایی است که یک نام ویژگی، عدد پرونده کاربر را عوض می‌کند. و برای اینکه این فهرست را واقعا روی سایت یا اپ خودتان بنشانید، [گذاشتن رویداد](/docs/instrument) را ببینید. و اگر می‌خواهید در پیام‌ها محصول پیشنهاد بدهید، شناسه‌ای که اینجا در `product_id` می‌گذارید باید با [کاتالوگ محصولات](/docs/catalog#the-id) یکی باشد.

## چطور این فهرست را بخوانید {#how-to-read-this}

ستون «وضعیت» در همه جدول‌ها دو مقدار دارد و فرقشان مهم است:

- **استاندارد** یعنی این نام در خود پلتفرم اعلام شده است و در پنل برچسب فارسی می‌گیرد. همین و نه بیشتر.
- **پیشنهادی** یعنی این نام قرارداد ماست. پلتفرم آن را نمی‌شناسد و مثل هر نام دیگری با آن رفتار می‌کند. می‌توانید عوضش کنید؛ فقط قبل از اولین ارسال عوضش کنید.

یک تصور غلط رایج را همین‌جا ببندیم: «استاندارد» به‌معنی «در فهرست انتخاب رویداد هست» نیست. سگمنت‌ساز تا بارگذاری اسکیمای واقعی تنانت، ده نام ثابت را نشان می‌دهد (`order_completed`، `product_viewed`، `product_added_to_cart`، `checkout_started`، `order_refunded`، `searched`، `app_opened`، `signed_up`، `message_opened`، `message_clicked`) و بعد از آن فهرست را از رویدادهای واقعی خودتان می‌سازد. سناریوساز هم فقط هشت نام را برچسب می‌زند. نام استانداردی که هرگز نفرستاده‌اید، در هیچ فهرستی ظاهر نمی‌شود.

سه چیزی که باید در فهرست «استاندارد» جدا نگه دارید:

**فقط سه نام واقعا رفتار دارند.** `order_completed` مبلغ را به `total_revenue` پرونده اضافه می‌کند، `order_count` را یکی بالا می‌برد و `last_order_at` را جلو می‌برد. `order_refunded` و `order_cancelled` قدرمطلق مبلغ را کم می‌کنند و شمارش سفارش را یکی پایین می‌آورند، هر دو با کف صفر. بقیه نام‌های استاندارد هیچ رفتاری ندارند.

**استاندارد یعنی شناخته‌شده، نه خودکار.** هیچ رویدادی خودبه‌خود فرستاده نمی‌شود. نام‌های `app_installed` و `app_opened` و `app_updated` و `app_removed` و `session_started` و `session_ended` در کد به‌عنوان «رویدادهای چرخه عمر که SDKها خودکار می‌فرستند» اعلام شده‌اند، ولی هیچ SDKای آن‌ها را نمی‌فرستد. اگر می‌خواهیدشان، خودتان بفرستید.

**نام‌های `message_*` را خودتان نفرستید.** آن‌ها را ورکرهای خود سگمنتیک می‌نویسند و در بخش [رویدادهایی که سگمنتیک می‌نویسد](/docs/event-dictionary#segmentic-writes) فهرست شده‌اند.

## نام‌هایی که پلتفرم می‌شناسد {#names-the-platform-knows}

| نام | برچسب در پنل | کد چه می‌کند |
|---|---|---|
| `order_completed` | خرید | درآمد و شمارش سفارش پرونده را بالا می‌برد، `last_order_at` را جلو می‌برد، و شمارنده `orders` در گزارش روزانه را می‌سازد |
| `order_refunded` | مرجوعی | درآمد و شمارش سفارش را کم می‌کند، با کف صفر |
| `order_cancelled` | لغو سفارش | همان کار `order_refunded` |
| `product_viewed` | مشاهده محصول | هیچ. فقط برچسب |
| `product_added_to_cart` | افزودن به سبد | هیچ |
| `product_removed_from_cart` | حذف از سبد | هیچ |
| `cart_viewed` | مشاهده سبد | هیچ |
| `checkout_started` | شروع پرداخت | هیچ |
| `searched` | جستجو | هیچ |
| `signed_up` | ثبت‌نام | هیچ |
| `signed_in` | ورود | هیچ |
| `app_opened` | باز کردن اپ | هیچ، و هیچ SDKای آن را نمی‌فرستد |
| `page_viewed` | مشاهده صفحه | نام پیش‌فرض یک پیام `page` بدون نام |
| `screen_viewed` | ندارد | نام پیش‌فرض یک پیام `screen` بدون نام |

## ویژگی‌هایی که روی هر رویداد می‌گذارید {#properties-on-every-event}

قبل از جدول‌ها، پنج قاعده که در هر شش کسب‌وکار یکی است.

**زمینه را دوباره نفرستید.** `context` همین حالا دستگاه، سیستم‌عامل، نسخه اپ، صفحه، کمپین و منطقه زمانی را حمل می‌کند و کلکتور آن‌ها را در ستون‌های خودشان می‌گذارد. ویژگی `platform` روی هر رویداد فقط یک نسخه بدتر از `device_type` است.

**شناسه‌ها را رشته بفرستید.** `"order_id": "A-100294"` نه `"order_id": 100294`. عدد به‌شکل متن هم ذخیره می‌شود، ولی رشته از اول با سیستم خودتان join می‌شود.

**عدد را عدد بفرستید و بولی را بولی.** `"quantity": 3` نه `"quantity": "3"`. رشته عددشکل هرگز به نقشه عددی نمی‌رود و فیلتر «بیشتر از» روی آن هیچ‌وقت جواب نمی‌دهد.

**برای مقدار ویژگی‌ها یک زبان انتخاب کنید و بمانید.** مقدارها همان‌طور که فرستاده می‌شوند ذخیره می‌شوند (فقط با نرمال‌سازی فارسی). `gold` و `طلایی` دو مقدار جدا هستند و در یک سگمنت نمی‌افتند.

**`revenue` و `total` و `value` و جفت `price` با `quantity` را فقط روی رویداد پولی بگذارید.** درآمد از هر رویدادی که این کلیدها را داشته باشد استخراج می‌شود و به ارزش طول عمر آن کاربر اضافه می‌شود. برای مبلغی که پول واقعی نیست از نام دیگری استفاده کنید. در جدول‌های زیر عمدا `unit_price` و `cart_total` و `amount` آمده است، چون هیچ‌کدام رزرو نیستند.

## فروشگاه اینترنتی {#online-retail}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `product_viewed` | استاندارد | صفحه محصول باز شد | `product_id` رشته، `product_name` رشته، `category` رشته، `brand` رشته، `unit_price` عدد، `currency` رشته، `in_stock` بولی |
| `searched` | استاندارد | جست‌وجو ثبت شد | `query` رشته، `results_count` عدد، `sort` رشته، `filters` رشته |
| `product_added_to_cart` | استاندارد | افزودن به سبد | `product_id` رشته، `product_name` رشته، `unit_price` عدد، `quantity` عدد، `cart_size` عدد |
| `product_removed_from_cart` | استاندارد | حذف از سبد | `product_id` رشته، `quantity` عدد، `cart_size` عدد |
| `cart_viewed` | استاندارد | صفحه سبد باز شد | `cart_size` عدد، `cart_total` عدد، `currency` رشته |
| `checkout_started` | استاندارد | ورود به مرحله پرداخت | `cart_size` عدد، `cart_total` عدد، `currency` رشته، `shipping_method` رشته، `coupon_code` رشته |
| `order_completed` | استاندارد | پرداخت موفق شد | `order_id` رشته، `revenue` عدد، `currency` رشته، `item_count` عدد، `shipping_cost` عدد، `discount` عدد، `coupon_code` رشته، `payment_method` رشته |
| `order_refunded` | استاندارد | مرجوعی تایید شد | `order_id` رشته، `revenue` عدد یعنی مبلغ برگشتی، `currency` رشته، `reason` رشته |
| `order_cancelled` | استاندارد | سفارش پیش از ارسال لغو شد | `order_id` رشته، `revenue` عدد، `currency` رشته، `cancelled_by` رشته |
| `signed_up` | استاندارد | ثبت‌نام کامل شد | `method` رشته، `referral_code` رشته |
| `signed_in` | استاندارد | ورود موفق | `method` رشته |
| `order_shipped` | پیشنهادی | مرسوله تحویل پست شد | `order_id` رشته، `carrier` رشته، `hours_since_order` عدد |
| `order_delivered` | پیشنهادی | مرسوله به دست مشتری رسید | `order_id` رشته، `carrier` رشته، `days_since_order` عدد |
| `review_submitted` | پیشنهادی | نظر ثبت شد | `product_id` رشته، `rating` عدد، `has_photo` بولی |
| `wishlist_item_added` | پیشنهادی | افزودن به علاقه‌مندی | `product_id` رشته، `unit_price` عدد |
| `back_in_stock_requested` | پیشنهادی | درخواست اطلاع از موجودی | `product_id` رشته |

قیف پیش‌فرضی که با همین فهرست کار می‌کند: `product_viewed` بعد `product_added_to_cart` بعد `checkout_started` بعد `order_completed`. سگمنت «سبد رهاشده» هم همان است: کسی که در هفت روز گذشته `product_added_to_cart` داشته و `order_completed` نداشته.

```bash title="یک سفارش کامل"
curl -X POST https://in.segmentic.net/v1/track \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "message_id": "b3d51f28-4c6a-4b1e-9d70-2f8a3c5e7d10",
    "user_id": "u_88123",
    "event": "order_completed",
    "timestamp": "2026-08-07T10:02:41.000Z",
    "properties": {
      "order_id": "A-100294",
      "revenue": 2450000,
      "currency": "IRR",
      "item_count": 3,
      "shipping_cost": 49000,
      "discount": 150000,
      "coupon_code": "NOWRUZ",
      "payment_method": "gateway"
    }
  }'
```

```json title="پاسخ"
{"status":"ok","accepted":1}
```

## سوپراپ و درخواست خودرو {#super-app}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `app_opened` | استاندارد | هر بار اپ به پیش‌زمینه می‌آید. خودتان باید بفرستید | `is_first_open` بولی، `source` رشته |
| `service_selected` | پیشنهادی | کاربر یکی از سرویس‌های سوپراپ را باز کرد | `service` رشته |
| `ride_requested` | پیشنهادی | درخواست سفر ثبت شد | `ride_id` رشته، `service_class` رشته، `origin_city` رشته، `destination_city` رشته، `estimated_fare` عدد، `surge_multiplier` عدد |
| `ride_accepted` | پیشنهادی | راننده پذیرفت | `ride_id` رشته، `wait_seconds` عدد |
| `ride_cancelled` | پیشنهادی | سفر پیش از پایان لغو شد | `ride_id` رشته، `cancelled_by` رشته، `seconds_to_cancel` عدد |
| `ride_completed` | پیشنهادی | سفر تمام شد و کرایه قطعی شد | `ride_id` رشته، `revenue` عدد، `currency` رشته، `distance_km` عدد، `duration_minutes` عدد، `payment_method` رشته |
| `ride_rated` | پیشنهادی | امتیاز ثبت شد | `ride_id` رشته، `rating` عدد |
| `order_completed` | استاندارد | سفارش غذا یا خرید فروشگاهی پرداخت شد | `order_id` رشته، `revenue` عدد، `currency` رشته، `vendor_id` رشته، `item_count` عدد، `service` رشته |
| `wallet_topped_up` | پیشنهادی | کیف پول شارژ شد | `revenue` عدد، `currency` رشته، `method` رشته |
| `promo_applied` | پیشنهادی | کد تخفیف اعمال شد | `coupon_code` رشته، `discount` عدد، `service` رشته |
| `support_ticket_opened` | پیشنهادی | تیکت پشتیبانی باز شد | `topic` رشته، `service` رشته، `ride_id` رشته |

اینجا `revenue` روی `wallet_topped_up` و `ride_completed` عمدی است. کد صریحا این حالت را در نظر گرفته: درآمد از هر رویدادی جمع می‌شود، نه فقط سفارش، چون مشتری ممکن است اشتراک یا شارژ را رویداد خودش بداند. اما توجه کنید که فقط `order_completed` شمارنده `order_count` را بالا می‌برد، پس «تعداد سفارش» در پرونده، سفرها را نمی‌شمارد.

ویژگی `service` را روی هر رویداد سوپراپ بگذارید. بدون آن نمی‌توانید بپرسید «کسی که تاکسی گرفته ولی هرگز غذا سفارش نداده».

## فین‌تک و پرداخت {#fintech}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `signed_up` | استاندارد | ثبت‌نام کامل شد | `method` رشته، `referral_code` رشته |
| `kyc_started` | پیشنهادی | کاربر وارد فرایند احراز هویت شد | `level` رشته، `hours_since_signup` عدد |
| `kyc_submitted` | پیشنهادی | مدارک ارسال شد | `level` رشته، `document_type` رشته |
| `kyc_approved` | پیشنهادی | احراز هویت تایید شد | `level` رشته، `hours_to_approve` عدد |
| `kyc_rejected` | پیشنهادی | احراز هویت رد شد | `level` رشته، `reason` رشته |
| `card_linked` | پیشنهادی | کارت بانکی متصل شد | `bank` رشته، `card_type` رشته |
| `transfer_completed` | پیشنهادی | انتقال وجه انجام شد | `transfer_id` رشته، `amount` عدد، `currency` رشته، `destination_type` رشته |
| `bill_paid` | پیشنهادی | قبض پرداخت شد | `bill_type` رشته، `amount` عدد، `currency` رشته |
| `payment_completed` | پیشنهادی | پرداخت در پذیرنده انجام شد | `revenue` عدد یعنی کارمزد شما، `currency` رشته، `merchant_id` رشته، `category` رشته، `amount` عدد |
| `loan_application_started` | پیشنهادی | درخواست تسهیلات آغاز شد | `amount` عدد، `term_months` عدد |
| `loan_approved` | پیشنهادی | تسهیلات تایید شد | `amount` عدد، `term_months` عدد، `days_to_decision` عدد |
| `investment_order_placed` | پیشنهادی | سفارش خرید یا فروش دارایی ثبت شد | `asset` رشته، `side` رشته، `amount` عدد |

اینجا `amount` عمدا به‌جای `revenue` آمده. مبلغ انتقال وجه، پول شما نیست: اگر آن را `revenue` بنامید، `total_revenue` هر کاربر برابر گردش حسابش می‌شود و سگمنت «مشتری پرارزش» عملا «کسی که زیاد پول جابه‌جا می‌کند» می‌شود. روی `payment_completed` هر دو آمده است، چون هر دو عدد را می‌خواهید: `revenue` کارمزدی که مال شماست و `amount` مبلغی که کاربر پرداخته.

> [!warn]
> شماره کارت، شماره شبا، `cvv` و توکن پرداخت را در ویژگی‌ها نگذارید. ویژگی‌های رویداد عینا در صفحه تایم‌لاین کاربر به تیم پشتیبانی نشان داده می‌شوند و در خروجی CSV هم می‌آیند. اگر لازم دارید، چهار رقم آخر را در یک ویژگی جدا مثل `card_last4` بگذارید.

## سفر و رزرو {#travel}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `searched` | استاندارد | جست‌وجوی مسیر یا اقامتگاه ثبت شد | `origin` رشته، `destination` رشته، `depart_date` رشته، `return_date` رشته، `passengers` عدد، `results_count` عدد |
| `product_viewed` | استاندارد | یک پرواز، هتل یا تور باز شد | `product_id` رشته، `product_type` رشته، `unit_price` عدد، `currency` رشته، `star_rating` عدد |
| `checkout_started` | استاندارد | فرایند رزرو شروع شد | `cart_total` عدد، `currency` رشته، `passengers` عدد، `product_type` رشته |
| `order_completed` | استاندارد | رزرو قطعی و پرداخت شد | `order_id` رشته، `revenue` عدد، `currency` رشته، `product_type` رشته، `origin` رشته، `destination` رشته، `depart_date` رشته، `days_to_departure` عدد |
| `order_cancelled` | استاندارد | رزرو لغو شد | `order_id` رشته، `revenue` عدد، `refund_amount` عدد، `days_to_departure` عدد |
| `trip_started` | پیشنهادی | روز حرکت رسید | `order_id` رشته، `product_type` رشته، `destination` رشته |
| `trip_completed` | پیشنهادی | سفر تمام شد | `order_id` رشته، `product_type` رشته، `nights` عدد |
| `price_alert_created` | پیشنهادی | هشدار قیمت ساخته شد | `origin` رشته، `destination` رشته، `target_price` عدد |
| `booking_modified` | پیشنهادی | تاریخ یا مسافر عوض شد | `order_id` رشته، `change_type` رشته، `fee` عدد |

`days_to_departure` روی خود رویداد ذخیره می‌شود، نه محاسبه در زمان پرس‌وجو. تفاوتش این است که چند ماه بعد هنوز می‌توانید بپرسید «چند نفر در فاصله کمتر از سه روز مانده به پرواز رزرو کردند»، در حالی که اگر فقط `depart_date` را داشته باشید باید در زمان گزارش دو رشته را تفریق کنید و رشته تاریخ در نقشه ویژگی‌ها متن است.

## آموزش {#education}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `signed_up` | استاندارد | ثبت‌نام کامل شد | `method` رشته، `referral_code` رشته |
| `course_viewed` | پیشنهادی | صفحه دوره باز شد | `course_id` رشته، `course_name` رشته، `category` رشته، `unit_price` عدد، `is_free` بولی |
| `course_enrolled` | پیشنهادی | ثبت‌نام در دوره انجام شد | `course_id` رشته، `is_free` بولی |
| `order_completed` | استاندارد | دوره یا اشتراک خریداری شد | `order_id` رشته، `revenue` عدد، `currency` رشته، `course_id` رشته |
| `lesson_started` | پیشنهادی | یک جلسه باز شد | `course_id` رشته، `lesson_id` رشته، `lesson_index` عدد |
| `lesson_completed` | پیشنهادی | جلسه تا انتها دیده شد | `course_id` رشته، `lesson_id` رشته، `lesson_index` عدد، `watch_seconds` عدد |
| `quiz_submitted` | پیشنهادی | آزمون ثبت شد | `course_id` رشته، `quiz_id` رشته، `score` عدد، `passed` بولی |
| `assignment_submitted` | پیشنهادی | تکلیف ارسال شد | `course_id` رشته، `assignment_id` رشته، `days_late` عدد |
| `certificate_issued` | پیشنهادی | گواهی صادر شد | `course_id` رشته، `days_to_complete` عدد |
| `course_abandoned` | پیشنهادی | مدتی است جلسه‌ای باز نشده و شما آن را رها شده می‌دانید | `course_id` رشته، `last_lesson_index` عدد، `days_idle` عدد |

`course_abandoned` را باید سیستم خودتان تصمیم بگیرد و از سرور بفرستد؛ پلتفرم رویدادی از روی نبود رویداد نمی‌سازد. اگر نمی‌خواهید آن را بسازید، همان کار را با یک سناریو انجام دهید که بعد از `lesson_completed` منتظر می‌ماند.

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

## اشتراک محتوا {#subscription-media}

| نام رویداد | وضعیت | چه وقت بفرستید | ویژگی‌ها |
|---|---|---|---|
| `signed_up` | استاندارد | حساب ساخته شد | `method` رشته |
| `content_viewed` | پیشنهادی | پخش یک قسمت یا خواندن یک مقاله شروع شد | `content_id` رشته، `content_type` رشته، `title` رشته، `genre` رشته، `is_premium` بولی |
| `content_completed` | پیشنهادی | تا انتها دیده یا خوانده شد | `content_id` رشته، `watch_seconds` عدد، `completion_ratio` عدد |
| `paywall_viewed` | پیشنهادی | دیوار پرداخت نمایش داده شد | `content_id` رشته، `plan_shown` رشته |
| `trial_started` | پیشنهادی | دوره آزمایشی شروع شد | `plan` رشته، `trial_days` عدد |
| `subscription_started` | پیشنهادی | اشتراک فعال شد و پول گرفته شد | `plan` رشته، `revenue` عدد، `currency` رشته، `billing_period` رشته |
| `subscription_renewed` | پیشنهادی | تمدید خودکار انجام شد | `plan` رشته، `revenue` عدد، `currency` رشته، `renewal_number` عدد |
| `subscription_cancelled` | پیشنهادی | کاربر لغو کرد | `plan` رشته، `reason` رشته، `days_subscribed` عدد |
| `payment_failed` | پیشنهادی | تمدید ناموفق بود | `plan` رشته، `attempt` عدد، `failure_code` رشته |
| `download_started` | پیشنهادی | دانلود برای تماشای آفلاین | `content_id` رشته، `content_type` رشته |

نکته‌ای که در اشتراک همیشه غافلگیر می‌کند: `subscription_cancelled` هیچ پولی را برنمی‌گرداند. فقط `order_refunded` و `order_cancelled` از `total_revenue` کم می‌کنند. لغو اشتراک یعنی تمدید بعدی نمی‌آید، نه اینکه پول ماه‌های قبل پس داده شود، و پرونده هم همین را نشان می‌دهد.

## ویژگی‌های پرونده که با identify می‌فرستید {#traits}

این‌ها کلیدهایی هستند که کد به آن‌ها معنی می‌دهد و به ستون واقعی پرونده تبدیل می‌شوند:

| کلید | نوع | نکته |
|---|---|---|
| `email` | رشته | فقط کوچک می‌شود، بدون بررسی قالب |
| `phone` | رشته | تجزیه و به شکل `E.164` ذخیره می‌شود، به‌علاوه `phone_operator` |
| `first_name` و `last_name` | رشته | همان‌طور که فرستاده می‌شود |
| `gender` | رشته | به `male` یا `female` یا `other` نگاشت می‌شود |
| `birthday` | تاریخ | قالب‌های `2006-01-02` و `2006/01/02` و `RFC3339` پذیرفته می‌شوند؛ تاریخ بدشکل بی‌صدا حذف می‌شود |
| `national_id` | رشته | رقم کنترلی بررسی می‌شود؛ نامعتبر کلا حذف می‌شود |
| `city` و `region` و `country` | رشته | اگر نفرستید، از موقعیت مکانی رویداد پر می‌شوند |
| `language` | رشته | اگر نفرستید، از بخش اول `locale` پر می‌شود |
| `timezone` | رشته | اگر نفرستید، از `context.timezone` رویداد پر می‌شود |
| `push_opt_in` و `email_opt_in` و `sms_opt_in` | بولی | مقدارهای درست: `true` و `1` و `yes` و `on` و «بله». هر چیز دیگری نادرست است |

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

`name` و `created_at` هم به‌عنوان کلید رزرو اعلام شده‌اند، ولی به ستون تبدیل نمی‌شوند و ویژگی معمولی می‌مانند. اگر نام کامل را می‌خواهید، `first_name` و `last_name` را بفرستید.

هر کلید دیگری ویژگی آزاد است و در دو نقشه `traits` و `traits_num` می‌نشیند. برای فروشگاه معمولا اینها را هم می‌فرستند: `loyalty_tier` رشته، `loyalty_points` عدد، `is_wholesale` بولی، `preferred_category` رشته.

```bash title="یک identify کامل"
curl -X POST https://in.segmentic.net/v1/identify \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "message_id": "c74a9e01-5f22-4a8b-8c31-7ee2d9b40a55",
    "user_id": "u_88123",
    "traits": {
      "phone": "09123456789",
      "email": "Hamid@Example.com",
      "first_name": "حمید",
      "city": "تهران",
      "birthday": "1993-04-11",
      "sms_opt_in": true,
      "loyalty_tier": "gold",
      "loyalty_points": 1840
    }
  }'
```

```json title="پاسخ"
{"status":"ok","accepted":1}
```

نتیجه روی پرونده: `phone` به `+989123456789` تبدیل می‌شود و `phone_operator` برابر `mci` نوشته می‌شود، `email` به `hamid@example.com` تبدیل می‌شود، `loyalty_points` هم در نقشه متنی و هم در نقشه عددی می‌نشیند تا فیلتر «بیشتر از» کار کند، و `loyalty_tier` فقط متنی می‌ماند.

## رویدادهایی که سگمنتیک می‌نویسد {#segmentic-writes}

این چهار نام را ورکرهای خود پلتفرم داخل جریان رویدادهای شما می‌نویسند. خودتان نفرستیدشان، ولی می‌توانید مثل هر رویداد دیگری روی آن‌ها سگمنت و قیف بسازید.

| نام | چه وقت نوشته می‌شود | ویژگی‌ها |
|---|---|---|
| `message_sent` | ارسال موفق یک پیام کمپین | `channel`، `category`، `transport`، `node_id`، `arm` که `treatment` یا `control` است، `can_open`، `can_click`. به‌علاوه `why_open` و `why_click` وقتی سنجش ممکن نباشد، که جمله‌های فارسی آماده چاپ‌اند |
| `message_failed` | ارسال شکست خورد | همان مجموعه، به‌علاوه `reason` |
| `message_withheld` | کاربر برای کمپین انتخاب شد و عمدا پیامی نرفت | همان مجموعه، با `arm` برابر `control` و `reason` برابر دلیل نگه‌داشتن |
| `message_opened` | نقطه ردیابی باز شدن پیام صدا زده شد | هیچ ویژگی‌ای ندارد. شناسه پیام کمپین در `source_message_id` می‌نشیند و دستگاه و موقعیت از همان درخواستی می‌آید که نرم‌افزار ایمیل زده است |

`message_id` این رویدادها ترکیب شناسه درخواست و نام رویداد است، تا ارسال و شکست یک پیام در حذف تکراری به هم نخورند.

نام‌های `message_delivered` و `message_bounced` و `unsubscribed` به‌عنوان ثابت اعلام شده‌اند و هیچ جای دیگر بک‌اند از آن‌ها استفاده نمی‌کند: نه ورکری آن‌ها را می‌نویسد و نه چیزی آن‌ها را می‌خواند، پس گزارشی که روی `message_delivered` بسازید خالی برمی‌گردد. با این حال خود پنل رویداد تحویل می‌خواهد. قالب داشبورد «عملکرد پیام‌رسانی» و متریک‌های پایه «نرخ تحویل پیام» و «نرخ بازشدن پیام» و «نرخ کلیک پیام» هرکدام به یک رویداد تحویل نیاز دارند که از اسکیمای رویداد خودتان به آن‌ها وصل شود، و تا اسمی ندهید فعال‌سازی جلو نمی‌رود، پس اگر خودتان رویداد تحویل نفرستید، آن داشبورد و آن سه متریک هرگز ساخته نمی‌شوند.

آنچه کم است رویداد است، نه داده. گزارش کمپین شمار پیام‌های تحویل‌شده خودش را از رسیدهایی می‌گیرد که مسیر ارسال پس می‌فرستد، سرویس تحویل‌پذیری برگشتی‌ها را از مسیر بازگشت SMTP می‌خواند و روی فهرست مسدود می‌نویسد، و لغو اشتراک روی پرونده آن آدم در مرکز تنظیمات می‌نشیند و در ارسال بعدی به‌عنوان دلیل سرکوب برمی‌گردد. هیچ‌کدام این‌ها به‌شکل سطر وارد جریان رویداد شما نمی‌شود.

ورکر انتساب دقیقا چهار نام را می‌شناسد، `message_sent` و `message_withheld` و `message_opened` و `message_clicked`، و هر رویداد دیگری را که به دستش برسد نادیده می‌گیرد. `message_clicked` را هم ورکر نمی‌نویسد: [SDK وب](/docs/sdk-web) آن را وقتی می‌فرستد که بازدیدکننده روی لینکی با `sg_mid` وارد سایت شود، یک بار، تا وقتی شناسه پیام دیگری جایش را بگیرد. مقایسه با همان یک شناسه داخل حافظه است نه با تاریخچه‌ای از شناسه‌ها، پس بازدیدکننده‌ای که روی یک پیام بیاید، بعد روی پیام دوم، و بعد دوباره روی همان پیام اول، کلیک پیام اول را دو بار گزارش می‌کند.

قدم بعد: [هویت](/docs/identity) می‌گوید این رویدادها چطور به یک آدم وصل می‌شوند، و [تعریف رویداد](/docs/events#hard-to-reverse) فهرست تصمیم‌هایی را دارد که بعدا برگشت ندارند.

---

# گذاشتن رویداد روی سایت و اپ خودتان

> از سؤال کسب‌وکار تا خط کدی که روی صفحه می‌نشیند: چه رویدادهایی لازم دارید، کجای کد صدایشان بزنید، و چطور مطمئن شوید رسیده‌اند.
> https://segmentic.net/docs/instrument

این صفحه فرض می‌کند سایت یا اپی دارید که کار می‌کند و هنوز هیچ رویدادی از آن بیرون نمی‌آید. [تعریف رویداد](/docs/events) می‌گوید کد ما با چیزی که می‌فرستید چه می‌کند و [فرهنگ‌نامهٔ رویدادها](/docs/event-dictionary) فهرست آمادهٔ هر صنف را دارد. این صفحه کار سومی می‌کند: از سؤالی که می‌خواهید جوابش را بدانید شروع می‌کند و به همان خط کدی می‌رسد که باید روی صفحهٔ شما بنشیند.

پیش از هر چیز، چیزی که خیلی‌ها انتظارش را دارند و وجود ندارد: **در سگمنتیک رویداد از قبل ثبت نمی‌شود.** هیچ فرمی، هیچ endpointی و هیچ صفحه‌ای برای «ساختن رویداد» نیست. هر نامی که بفرستید پذیرفته می‌شود و رویداد در همان لحظهٔ اولین ارسال وجود پیدا می‌کند. کاری که واقعا انجام می‌دهید انتخاب نام و ویژگی‌هاست، و بعد گذاشتن یک فراخوانی در جای درست کد خودتان.

این آزادی دو طرف دارد. طرف خوبش این است که برای اضافه کردن یک رویداد تازه به هیچ‌کس احتیاج ندارید. طرف بدش این است که هیچ نگهبانی جلوی نام غلط را نمی‌گیرد، و نام غلط بعدا اصلاح نمی‌شود. برای همین ترتیب این صفحه از فهرست شروع می‌شود، نه از کد.

## از سؤال شروع کنید، نه از صفحه {#what-to-decide-first}

وسوسهٔ اول این است که آدم صفحه‌های سایتش را مرور کند و هرچه می‌شود کلیک کرد را بفرستد. نتیجه‌اش هشتاد نام است که هیچ‌کدام به هیچ تصمیمی وصل نیست.

راه درست برعکس است: سه تا پنج سؤال بنویسید که اگر جوابشان را داشتید کاری می‌کردید. بعد برای هر سؤال بپرسید کدام رویداد جوابش را می‌دهد.

| سؤالی که می‌پرسید | کاری که با جوابش می‌کنید | رویدادی که لازم دارید |
| --- | --- | --- |
| چه کسانی سبد را رها کردند | برایشان یادآوری می‌فرستم | `cart_updated` و `order_completed` |
| کدام بنر صفحهٔ اصلی کار می‌کند | بنر ضعیف را عوض می‌کنم | `banner_viewed` و `banner_clicked` |
| چه کسانی محصول گران را دیدند و نخریدند | تخفیف هدفمند می‌دهم | `product_viewed` با `price` |
| چه کسی سی روز است برنگشته | کمپین بازگشت می‌سازم | هر رویدادی، تاریخ آخرین بازدید کافی است |

سطر آخر عمدا آنجاست: بعضی سؤال‌ها هیچ رویداد تازه‌ای لازم ندارند. قبل از اضافه کردن نام جدید، ببینید چیزی که همین حالا می‌فرستید جواب را نمی‌دهد.

## فهرست را روی کاغذ ببندید {#the-list}

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

| نام رویداد | کی صدا زده می‌شود | ویژگی‌ها | چه کسی می‌فرستد |
| --- | --- | --- | --- |
| `banner_viewed` | وقتی بنر واقعا وارد کادر دید شد | `banner_id`, `slot` | مرورگر |
| `banner_clicked` | کلیک روی بنر | `banner_id`, `slot`, `destination` | مرورگر |
| `product_viewed` | باز شدن صفحهٔ محصول | `product_id`, `category`, `price` | مرورگر |
| `checkout_started` | ورود به صفحهٔ پرداخت | `cart_value`, `item_count` | مرورگر |
| `order_completed` | تایید نهایی پرداخت | `order_id`, `revenue`, `currency` | سرور |

ستون آخر مهم‌ترین ستون است و معمولا از قلم می‌افتد. جوابش در بخش [چیزهایی که مرورگر نباید بگوید](/docs/instrument#from-the-server) است.

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

## چند تا رویداد کافی است {#how-many}

بین پنج تا پانزده تا، برای شروع.

سقف فنی‌ای در کار نیست و هیچ‌کس شمارشتان نمی‌کند. محدودیت جای دیگری است: کاتالوگ رویدادها در پنل، پانصد نام پرحجم‌تر در نود روز گذشته را نشان می‌دهد. اگر نام‌های بی‌مصرف زیاد شوند، رویدادهای واقعی از این فهرست می‌افتند بیرون و در سگمنت‌ساز قابل انتخاب نمی‌مانند. یک بار برای یک مشتری واقعی همین اتفاق افتاد، با نام‌هایی که شکل آدرس داشتند؛ شرحش در [وقتی نام رویداد یک آدرس است](/docs/events#url-shaped-names) هست.

محدودیت دوم انسانی است. رویدادی که هیچ سگمنت، هیچ گزارش و هیچ سناریویی از آن استفاده نمی‌کند، رویدادی است که شش ماه بعد کسی نمی‌داند چرا فرستاده می‌شود و جرئت هم نمی‌کند حذفش کند.

> [!note]
> کم شروع کنید. اضافه کردن یک رویداد جدید در هر لحظه ممکن است و هیچ هزینه‌ای ندارد. برداشتن یک نام غلط از تاریخچه ممکن نیست.

## نام گذاشتن روی رویداد خودتان {#naming-yours}

سرور فقط دو چیز را چک می‌کند: نام از ۱۲۸ بایت بلندتر نباشد و کاراکتر کنترلی نداشته باشد. نه حروف را کوچک می‌کند، نه فاصله را به زیرخط تبدیل می‌کند، نه هیچ الگویی را اجبار می‌کند. قاعدهٔ کامل در [قاعدهٔ نام‌گذاری](/docs/events#naming) است.

یعنی هر چیزی که در ادامه می‌آید قرارداد است، نه اجبار. ولی قراردادی است که نام‌های استاندارد خود پلتفرم از آن پیروی می‌کنند، و همسو ماندن با آن یعنی فهرست رویدادهایتان مرتب می‌ماند.

| قاعده | بنویسید | ننویسید |
| --- | --- | --- |
| اسم شیء اول، بعد فعل گذشته | `banner_clicked` | `click_banner` |
| حروف کوچک و زیرخط | `wallet_topped_up` | `WalletToppedUp` |
| هیچ متغیری داخل نام نباشد | `banner_clicked` با `banner_id` | `banner_nowruz_hero_clicked` |
| یک نام برای یک اتفاق | `product_viewed` | `product_view` و `productViewed` کنار هم |

سطر دوم و چهارم یک تلهٔ مشترک دارند: چون سرور حروف را کوچک نمی‌کند، `Banner_Clicked` و `banner_clicked` تا ابد دو رویداد جداگانه‌اند. هیچ خطایی هم نمی‌گیرید. فقط یک روز می‌بینید عددها نصف شده‌اند.

سطر سوم پرهزینه‌ترین است. نامی که داخلش شناسه یا مقدار متغیر دارد، به ازای هر مقدار یک رویداد تازه می‌سازد و همان چیزی است که کاتالوگ را پر می‌کند.

## یک نام، چند جای مختلف {#one-name-many-places}

بنر شما هم در صفحهٔ اصلی هست، هم بالای صفحهٔ دسته‌بندی، هم داخل ایمیل. سه نام نسازید.

یک نام بگذارید و جای وقوع را در یک ویژگی بفرستید:

```js
Segmentic.track("banner_clicked", {
  banner_id: "nowruz_hero",
  slot: "homepage_top"
});
```

دلیلش این است که سؤال‌های شما هر دو شکل را می‌خواهند. «کل کلیک روی این بنر چقدر بود» با یک نام جواب می‌گیرد، و «کدام جایگاه بهتر کار می‌کند» با شکستن روی همان ویژگی. اگر سه نام بسازید، سؤال اول دیگر جواب ساده ندارد.

قاعدهٔ کلی: چیزی که می‌خواهید روی آن **جمع بزنید** نام است، و چیزی که می‌خواهید روی آن **بشکنید** ویژگی است.

## کجای کد صدایش بزنید {#where-in-the-page}

اینجا جایی است که پیاده‌سازی‌ها خراب می‌شوند. رویداد در لحظهٔ غلط، بدتر از رویداد نفرستادن است، چون عدد می‌دهد و عددش غلط است.

| اتفاق | کجا صدا بزنید | چرا نه جای دیگر |
| --- | --- | --- |
| کلیک روی لینک یا دکمه | یک listener روی `document` با `closest` | بنرها معمولا بعدا یا داخل اسلایدر رندر می‌شوند و listener مستقیم به آن‌ها نمی‌رسد |
| ثبت فرم | بعد از پاسخ موفق سرور | روی `submit` رویداد را برای فرم‌هایی هم می‌فرستید که رد شده‌اند |
| دیده شدن یک بخش | با `IntersectionObserver` | روی لود صفحه یعنی هر بازدیدکننده‌ای که تا پایین اسکرول نکرده هم شمرده می‌شود |
| رفتن به صفحهٔ دیگر در اپ تک‌صفحه‌ای | خود SDK با `autoPageView` | فراخوانی دستی در `useEffect` روی هر رندر دوباره می‌فرستد |
| پرداخت موفق | از سرور | مرورگر خبر ندارد پول واقعا نشسته است یا نه |

نگرانی رایج بعدی این است که کاربر روی لینک کلیک می‌کند و صفحه عوض می‌شود، پس رویداد فرصت رفتن پیدا نمی‌کند. این یکی حل شده است: SDK وب به‌صورت پیش‌فرض هر بیست پیام یا هر ده ثانیه صف را خالی می‌کند و علاوه بر آن روی `pagehide` و مخفی شدن صفحه با `sendBeacon` می‌فرستد، که کندن صفحه را دوام می‌آورد. جزئیاتش در [صف آفلاین](/docs/sdk-web#unload) است.

## یک مثال کامل: کلیک روی بنر {#worked-example}

فرض کنید سؤالتان این است: «کدام بنر صفحهٔ اصلی کار می‌کند و چه کسانی رویش کلیک کردند.»

اول SDK را نصب کنید. کلید نوشتن را از صفحهٔ «اتصال» در پنل بردارید:

```html
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_...",
    apiHost: "https://in.segmentic.net"
  });
</script>
```

بعد به خود بنرها یک نشانه بدهید. اینکه شناسه در HTML بنشیند یعنی برای بنر بعدی لازم نیست کسی جاوااسکریپت را دست بزند:

```html
<a href="/campaign/nowruz"
   data-banner="nowruz_hero"
   data-slot="homepage_top">
  <img src="/banners/nowruz.jpg" alt="جشنوارهٔ نوروز">
</a>
```

بعد یک listener، یک بار، برای همهٔ بنرهای سایت:

```js
document.addEventListener("click", function (e) {
  var el = e.target.closest("[data-banner]");
  if (!el) return;

  Segmentic.track("banner_clicked", {
    banner_id: el.dataset.banner,
    slot: el.dataset.slot,
    destination: el.getAttribute("href")
  });
});
```

و اگر نرخ کلیک می‌خواهید، دیده شدن را هم بفرستید. `once` مهم است، وگرنه هر بار که بنر از کادر دید بیرون و تو برود دوباره شمرده می‌شود:

```js
var seen = new WeakSet();
var io = new IntersectionObserver(function (entries) {
  entries.forEach(function (entry) {
    if (!entry.isIntersecting || seen.has(entry.target)) return;
    seen.add(entry.target);
    var el = entry.target;
    Segmentic.track("banner_viewed", {
      banner_id: el.dataset.banner,
      slot: el.dataset.slot
    });
  });
}, { threshold: 0.5 });

document.querySelectorAll("[data-banner]").forEach(function (el) {
  io.observe(el);
});
```

همین. هیچ ثبتی، هیچ migrationی و هیچ تنظیمی در پنل لازم نیست. رویداد `banner_clicked` از اولین کلیک وجود دارد.

## داخل اپ موبایل {#in-an-app}

نام‌ها را عوض نکنید. همان `banner_clicked` که وب می‌فرستد، اپ هم باید بفرستد. `banner_clicked_android` نسازید، چون آن‌وقت هر سؤالی دو بار پرسیده می‌شود و هر گزارشی دو ستون دارد.

پلتفرم را خود SDK در `context` می‌گذارد و در گزارش‌ها قابل شکستن است، پس لازم نیست در نام یا ویژگی تکرارش کنید.

نصب و متدها در [SDK اندروید](/docs/sdk-android#api) است. جای فراخوانی همان منطق بالا را دارد: در لحظهٔ تعامل واقعی، نه در `onCreate` و نه در سازندهٔ ویو.

## چیزهایی که مرورگر نباید بگوید {#from-the-server}

کلید نوشتن عمدا عمومی است. داخل صفحهٔ شماست و هر کسی می‌تواند ببیندش و با آن رویداد بفرستد. برای `banner_clicked` این اهمیتی ندارد. برای عددی که در گزارش درآمد می‌نشیند اهمیت دارد.

پس این‌ها را از بک‌اند خودتان بفرستید نه از مرورگر:

- هر رویدادی که مبلغ دارد، به‌خصوص `order_completed` و `revenue` آن
- هر چیزی که وضعیت رسمی است: ارسال شد، مرجوع شد، اشتراک تمدید شد
- هر چیزی که مرورگر اصلا از آن خبر ندارد، مثل تایید درگاه پرداخت که به صورت callback به سرور شما می‌آید

دو در برای این کار هست و تفاوتشان در [سرور به سرور](/docs/server#two-doors) کامل باز شده. کوتاهش: `POST /v1/batch` روی `https://in.segmentic.net` با همان کلید نوشتن، یا `POST /v1/events` روی `https://api.segmentic.net` با کلید محرمانهٔ `sk_seg_`.

> [!warn]
> در دوم دو تفاوت بی‌صدا دارد: تکراری‌ها را حذف نمی‌کند، پس یک retry ساده در کد شما رویداد را دو بار می‌سازد؛ و پنجرهٔ زمانش ثابت سی روز است. برای انتقال تاریخچه از این در استفاده نکنید.

اگر همان رویداد را هم از مرورگر و هم از سرور بفرستید، دو تا می‌شود. یکی را انتخاب کنید. برای رویدادهای پولی، همیشه سرور.

## وصل کردن رویداد به یک آدم {#tie-to-a-person}

رویدادی که فرستادید تا اینجا فقط `anonymous_id` دارد. یعنی می‌دانید یک مرورگر روی بنر کلیک کرد، ولی نمی‌دانید کی بود و نمی‌توانید برایش ایمیل بفرستید.

`identify` همان چیزی است که این را وصل می‌کند. هر جا کاربر را می‌شناسید صدایش بزنید: بعد از ورود، بعد از ثبت‌نام، یا روی هر صفحه‌ای اگر نشست باز دارید.

```js
Segmentic.identify("u_8842", {
  email: "ali@example.com",
  phone: "09121234567"
});
```

نکتهٔ مهم: لازم نیست `identify` قبل از کلیک اتفاق بیفتد. SDK در اولین `identify` خودش یک `alias` هم می‌فرستد، پس تاریخچهٔ ناشناس همان مرورگر به پرونده وصل می‌شود. اگر این کار نمی‌شد، هر قیفی که از مرز ورود رد شود عدد غلط می‌داد.

روی خروج از حساب `Segmentic.reset()` را صدا بزنید، وگرنه رویدادهای نفر بعدی روی همان دستگاه به پروندهٔ نفر قبلی می‌چسبد. حالت‌های دشوارتر، مثل دستگاه مشترک، در [هویت](/docs/identity) است.

## بررسی کنید هر کدام واقعا رسیده {#verify-each-one}

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

مرحلهٔ اول، **همان لحظه**: صفحهٔ «دیباگ» را در پنل باز بگذارید، در تب دیگری روی بنر کلیک کنید و ببینید ظرف چند ثانیه می‌آید. اگر نیامد مشکل از نصب است نه از نام رویداد. یک نکته: ارسال دسته‌ای در این صفحه ضبط نمی‌شود، پس برای آزمایش از فراخوانی تکی استفاده کنید.

مرحلهٔ دوم، **چند دقیقه بعد**: صفحهٔ «داده» را باز کنید. رویداد باید با حجمش و فهرست ویژگی‌هایش آنجا باشد. اگر نام هست ولی ویژگی‌ای که انتظار داشتید نیست، یعنی مقدارش `null` یا خالی بوده است.

مرحلهٔ سوم، **همان‌جا**: برچسب فارسی رویداد را بگذارید. «کلیک روی بنر» برای کسی که سگمنت می‌سازد خواناتر از `banner_clicked` است. برچسب فقط نمایشی است و نام ذخیره‌شده هیچ‌وقت عوض نمی‌شود.

> [!warn]
> جدول `ingest_warnings` وجود دارد ولی چیزی در آن نوشته نمی‌شود. هشدارها فقط در بدنهٔ پاسخ همان درخواست هستند، پس اگر ارسال از سرور می‌کنید، آن پاسخ را لاگ کنید. راه‌های دیدنش در [دیدن اینکه واقعا چه رسید](/docs/events#what-arrived).

## چک‌لیست پیش از انتشار {#go-live-checklist}

- هر رویدادی که در فهرست نوشتید یک بار در صفحهٔ دیباگ دیده شده است.
- هیچ نامی متغیر داخلش ندارد، و همه با یک الگوی حروف نوشته شده‌اند.
- شناسه‌ها رشته‌اند و عددها عدد، نه رشتهٔ عددی. قاعده‌اش در [یک ویژگی چه چیزی می‌تواند نگه دارد](/docs/events#property-types) است.
- `revenue` فقط روی رویدادهای واقعا پولی نشسته است. روی هر رویدادی که باشد به ارزش عمر مشتری اضافه می‌شود، حتی روی `cart_viewed`. شرحش در [درآمد](/docs/events#revenue).
- `identify` جایی صدا زده می‌شود که کاربر شناخته می‌شود، و `reset` روی خروج.
- رویدادهای پولی از سرور می‌آیند، نه از مرورگر.
- هیچ رویدادی دو بار، یک بار از مرورگر و یک بار از سرور، فرستاده نمی‌شود.
- برچسب فارسی هر رویداد در صفحهٔ «داده» گذاشته شده است.

## اشتباه‌های رایج {#common-mistakes}

| اشتباه | چه چیزی خراب می‌شود | به جایش |
| --- | --- | --- |
| نام حاوی شناسه یا آدرس | کاتالوگ پر می‌شود و رویدادهای واقعی از فهرست پانصدتایی می‌افتند | شناسه را ویژگی کنید |
| رویداد روی `submit` فرم | فرم‌های ردشده هم شمرده می‌شوند | بعد از پاسخ موفق |
| `revenue` روی رویداد غیرپولی | ارزش عمر مشتری بی‌سروصدا باد می‌کند | فقط روی خرید |
| فرستادن `url` و `referrer` به‌عنوان ویژگی | جای ویژگی را می‌گیرد بی‌آنکه چیزی اضافه کند | SDK خودش در `context` می‌فرستد |
| مقدار فارسی برای دسته‌بندی‌ها | فیلتر روی املای مختلف می‌شکند | یک زبان برای مقادیر، برچسب را در پنل بگذارید |
| `identify` فقط در صفحهٔ ورود | کسی که با نشست باز برمی‌گردد ناشناس می‌ماند | هر جا نشست معتبر است |
| نبود `reset` روی خروج | رویدادهای دو نفر روی یک پرونده جمع می‌شود | `reset` در مسیر خروج |
| رویداد هم از مرورگر هم از سرور | عددها دو برابر می‌شوند | یکی را انتخاب کنید |

## بعد چه بخوانید {#next}

- [تعریف رویداد](/docs/events) برای قاعده‌های دقیق نام، ویژگی و سقف‌ها.
- [فرهنگ‌نامهٔ رویدادها](/docs/event-dictionary) اگر فروشگاه، فین‌تک، سفر یا آموزش دارید و فهرست آماده می‌خواهید.
- [هویت](/docs/identity) اگر کاربر مهمان دارید که بعدا وارد می‌شود.
- [SDK وب](/docs/sdk-web#methods) برای همهٔ متدها و تنظیمات.
- [سرور به سرور](/docs/server#which-one) برای انتخاب بین دو در.
- [سگمنت‌ها](/docs/segments) وقتی رویدادها رسیدند و می‌خواهید رویشان سگمنت بسازید.
- [کاتالوگ محصولات](/docs/catalog) اگر فروشگاه دارید و می‌خواهید در پیام‌ها محصول پیشنهاد بدهید.

---

# هویت: کاربر مهمان و کاربر واردشده

> چطور تاریخچهٔ کسی که هنوز وارد نشده به حسابش وصل می‌شود، و چه اتفاقی می‌افتد وقتی دو نفر روی یک دستگاه وارد می‌شوند.
> https://segmentic.net/docs/identity

بیشتر آدم‌ها پیش از آنکه بدانید کی هستند، چند بار به شما سر می‌زنند. این صفحه می‌گوید سگمنتیک با آن مدت چه می‌کند: چه چیزی وصل می‌شود، چه چیزی وصل نمی‌شود، و کدام کار برگشت ندارد.

> نمودار: تبدیل شناسه مهمان، شناسه کاربر و ویژگی تاییدشده به یک نمای یکپارچه از مشتری

## دو شناسه {#two-identifiers}

| | `anonymous_id` | `user_id` |
|---|---|---|
| از کجا می‌آید | SDK می‌سازدش | سیستم احراز هویت خودتان می‌دهدش |
| کجا نگه داشته می‌شود | حافظه محلی مرورگر یا فایل خصوصی اپ | همان‌جا، بعد از اولین `identify` |
| کی عوض می‌شود | فقط با `reset` | با `identify` بعدی، یا `reset` |
| پرونده می‌سازد | خیر | بله |

دست‌کم یکی از این دو باید روی هر پیام باشد، وگرنه رویداد با کد `missing_identity` رد می‌شود. هر دو حداکثر ۲۵۶ بایت‌اند و بلندتر از آن `id_too_long` می‌گیرد.

هر پیام با کلید هویت به صف رویدادها می‌رود: اگر `user_id` خالی نباشد همان، وگرنه `anonymous_id`. دلیلش ترتیب است: همه پیام‌های یک آدم باید روی یک پارتیشن بنشینند تا مصرف‌کننده‌های حالت‌دار، یعنی به‌روزرسانی پرونده و وضعیت سناریو و دوختن نشست، آن‌ها را به‌ترتیب ببینند بدون هیچ هماهنگی بین پارتیشن‌ها.

نکته‌ای که کل بقیه این صفحه از آن درمی‌آید: **پرونده فقط با `user_id` کلید می‌خورد.** ingestor هر رویدادی را که `user_id` خالی دارد پیش از رسیدن به پرونده‌ها رد می‌کند. یعنی بازدیدکننده مهمان اصلا پرونده‌ای ندارد.

## شناسه مهمان چطور ساخته و نگه داشته می‌شود {#anonymous-id}

**مرورگر.** SDK وب کلید `segmentic_anonymous_id` را در `localStorage` می‌خواند و اگر نبود یکی می‌سازد. ساختش با `crypto.randomUUID` است، و اگر مرورگر آن را نداشته باشد با `crypto.getRandomValues` یک UUID نسخه چهار می‌سازد، و در آخرین حالت برای مرورگرهای خیلی قدیمی از `Math.random` استفاده می‌کند.

پیش از همه این‌ها، `localStorage` با یک نوشتن واقعی آزمایش می‌شود، نه با بررسی وجود داشتنش. در حالت ناشناس سافاری، در مرورگرهای سخت‌گیر سازمانی، و وقتی سهمیه دامنه پر شده باشد، `localStorage` وجود دارد و در نوشتن خطا می‌دهد. اگر آزمایش شکست بخورد SDK به یک انبار درون‌حافظه‌ای می‌افتد: سایت مشتری سالم می‌ماند، ولی شناسه مهمان فقط تا پایان همان صفحه زنده است و هر بارگذاری بعدی یک آدم تازه است.

**اندروید و iOS.** همان کلید، ولی به‌صورت یک فایل به همان نام داخل پوشه `segmentic` در حافظه خصوصی اپ. ساختش با `UUID.randomUUID` است. تا وقتی اپ پاک نشود می‌ماند.

سه چیزی که شناسه مهمان **نیست**:

- شناسه دستگاه نیست. اپل یک شناسه نصب جدا به نام `segmentic_install_id` دارد که عمدا نه `identifierForVendor` است (که با حذف آخرین اپ آن سازنده عوض می‌شود) و نه شناسه تبلیغاتی (که سوالی است که مشتری باید جوابش را بدهد، نه ما).
- بین دو مرورگر یا دو دستگاه مشترک نیست. یک آدم روی گوشی و لپ‌تاپ، دو شناسه مهمان دارد.
- بعد از `identify` عوض نمی‌شود. همان مقدار روی هر پیام بعدی هم می‌نشیند.

هر پیامی که SDK می‌سازد همیشه `anonymous_id` را حمل می‌کند، و `user_id` را وقتی که بداند.

## `identify` چه می‌کند {#identify}

سمت کلاینت، در هر سه SDK، به همین ترتیب:

1. اگر شناسه کاربر خالی باشد، فراخوانی نادیده گرفته می‌شود و یک هشدار در کنسول می‌آید.
2. شناسه کاربر ذخیره می‌شود.
3. **اگر شناسه کاربر با آنچه ذخیره بود فرق داشته باشد و شناسه مهمانی وجود داشته باشد، اول یک پیام `alias` به صف می‌رود**، با `previous_id` برابر شناسه مهمان فعلی.
4. بعد پیام `identify` با ویژگی‌ها به صف می‌رود.
5. در وب و اندروید، ویژگی‌های ساده به‌صورت محلی کش می‌شوند تا هدف‌گیری پیام درون‌برنامه‌ای بتواند رویشان شرط بگذارد. مرورگر فقط آنچه را دارد که به آن داده شده، نه انبار داده؛ اگر وانمود می‌کردیم که دارد، هر قاعده ویژگی روی هر کمپین بی‌صدا نادرست می‌شد. SDK iOS این کش را ندارد.

`anonymous_id` عوض **نمی‌شود**. هر دو پیام `alias` و `identify` همان شناسه مهمان و شناسه کاربر جدید را با هم دارند.

`identify` را دو بار با یک شناسه صدا بزنید، دقیقا یک `alias` ساخته می‌شود. مقایسه با مقدار ذخیره‌شده انجام می‌شود، نه با حافظه همان اجرا، پس بارگذاری دوباره صفحه هم یک alias تکراری نمی‌سازد.

سمت سرور، `identify` یک رویداد معمولی به نام `identify` می‌شود. ویژگی‌های آن روی جدول رویدادها ذخیره **نمی‌شوند**؛ فقط به پرونده کاربر می‌روند. پرونده دو قاعده دارد: هرگز پاک نکن (رویدادی که ویژگی‌ای را نام نبرده باید آن را دست‌نخورده بگذارد) و هرگز عقب نرو (رویدادها بعد از راه‌اندازی دوباره مصرف‌کننده بی‌ترتیب پخش می‌شوند، پس رویداد قدیمی‌تر نباید حالت جدیدتر را بازنویسی کند).

```js title="مرورگر"
import { init, identify } from "@segmentic/web";

init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
});

// After your own sign-in succeeds, and again on every page load
// while the session is still valid.
identify("u_88123", {
  phone: "09123456789",
  first_name: "حمید",
  loyalty_tier: "gold",
});
```

```kotlin title="اندروید"
Segmentic.identify(
    userId = "u_88123",
    traits = mapOf(
        "phone" to "09123456789",
        "first_name" to "حمید",
        "loyalty_tier" to "gold",
    ),
)
```

## `alias` و `previous_id` {#alias}

`identify` خودش alias را می‌فرستد، پس معمولا لازم نیست خودتان صدایش بزنید. اگر صدا زدید، `previous_id` اجباری است و بدون آن پیام با کد `missing_previous_id` رد می‌شود.

یک نامتقارنی که در جدول حدها پیدا نمی‌کنید: برخلاف `user_id` و `anonymous_id` که در ۲۵۶ بایت رد می‌شوند، روی `previous_id` هیچ بررسی طولی نیست. هرچه بفرستید سالم تا `identity_map` می‌رود و تنها سقفش همان ۵ مگابایت بدنه است.

آنچه یک پیام alias سمت سرور تولید می‌کند، دقیقا این است:

```sql title="segmentic.identity_map"
CREATE TABLE segmentic.identity_map (
    tenant_id    UInt32,
    anonymous_id String,
    user_id      String,
    linked_at    DateTime64(3, 'UTC')
) ENGINE = ReplacingMergeTree(linked_at)
PARTITION BY tenant_id
ORDER BY (tenant_id, anonymous_id);
```

یک سطر. به‌علاوه خود پیام alias هم مثل هر پیام دیگری به‌عنوان رویدادی به نام `alias` در جدول رویدادها می‌نشیند.

توجه کنید که کلید مرتب‌سازی `anonymous_id` است، نه `user_id`. تمام بخش [دو نفر روی یک دستگاه](/docs/identity#two-people-one-device) از همین یک جمله درمی‌آید.

`alias` دو `user_id` را به هم وصل نمی‌کند. هر مقداری که در `previous_id` بگذارید در ستون سمت مهمان می‌نشیند، و هیچ‌چیزی دو پرونده را ادغام نمی‌کند. **هیچ عملیاتی برای ادغام دو پرونده وجود ندارد.**

## این پیوند به چه کار می‌آید {#what-the-link-does}

`identity_map` سه مصرف‌کننده در کل کد دارد: نوشتن سطر، مسیر پاک‌سازی داده یک شخص، و تست یکپارچگی خودش. همین.

کاری که واقعا با آن انجام می‌شود پاک‌سازی است. وقتی کسی می‌خواهد فراموش شود، ردیف‌های رویدادش با `user_id` پاک می‌شوند، ولی رویدادهای پیش از ورودش `user_id` خالی دارند و آن پاس اصلا آن‌ها را نمی‌بیند. تنها راه رسیدن به آن‌ها، شناسه‌های مهمانی است که در `identity_map` نام برده شده‌اند:

```sql title="پاک‌سازی رویدادهای پیش از ورود"
ALTER TABLE segmentic.events DELETE
WHERE tenant_id = ? AND user_id = '' AND anonymous_id IN (
    SELECT anonymous_id FROM segmentic.identity_map
    WHERE tenant_id = ? AND user_id = ?
) SETTINGS mutations_sync = 2;
```

و بعد از آن، نه پیش از آن، خود `identity_map` پاک می‌شود. ترتیب کل ماجراست: نسخه اول این کد نقشه را اول پاک می‌کرد، پس زیرپرس‌وجو به هیچ‌چیز نمی‌خورد، حذف چیزی برنمی‌داشت و موفقیت گزارش می‌کرد. تاریخچه گشت‌زنی آن آدم پیش از ورود، که بخش بزرگی از معنی «مرا فراموش کن» است، بی‌صدا از هر پاک‌سازی جان سالم به در می‌برد. یک تست یکپارچگی این را گرفت.

## آنچه اتفاق نمی‌افتد {#not-stitched}

این مهم‌ترین بخش این صفحه است و به‌عمد صریح نوشته شده، چون اگر خلافش را باور کنید یک بعدازظهر را روی عددی می‌گذارید که هرگز درست نمی‌شود.

**سطرهای رویداد مهمان هرگز بازنویسی نمی‌شوند.** هیچ کدی `events.user_id` را برای سطرهایی که شناسه مهمانشان در نقشه هویت آمده به‌روز نمی‌کند. آن سطرها برای همیشه `user_id` خالی دارند.

**تابع ادغام پرونده مهمان با پرونده شناخته‌شده در کد هست و هیچ‌جا صدا زده نمی‌شود.** جست‌وجوی کل مخزن برای `ApplyAlias` تعریفش را در `profile/merge.go` پیدا می‌کند و فراخوانی‌هایی که همه‌شان در `merge_test.go` هستند، و هیچ‌چیز دیگر.

**بازدیدکننده مهمان اصلا پرونده‌ای ندارد.** ingestor هر رویداد با `user_id` خالی را رد می‌کند، پس پرونده مهمانی وجود ندارد که ادغام شود. یعنی `total_events` و `total_revenue` و `first_seen` آن آدم از لحظه ورودش شروع می‌شود.

**هیچ پرس‌وجوی تحلیلی به `identity_map` جوین نمی‌زند.** کامپایلر سگمنت، گزارش قیف و ماندگاری و مسیر، و تایم‌لاین کاربر، همه مستقیم `events.user_id` را می‌خوانند.

**سطرهای مهمان از هر دو گزارش تجمیعی روزانه بیرون‌اند**، با شرط `user_id != ''`.

نتیجه عملی، دقیق: قیفی که با یک `product_viewed` مهمان شروع شود و با یک `order_completed` واردشده تمام شود، این دو را به هم وصل نمی‌کند، چون زیرپرس‌وجوی رویداد در کامپایلر روی `user_id` گروه می‌شود و مقدار آن سطر مهمان خالی است.

> [!warn]
> هرجا گفته شده «تاریخچه ناشناس به کاربر وصل می‌شود»، معنی دقیقش این است: سطرها نگه داشته می‌شوند و پیوند ثبت می‌شود. معنی‌اش این نیست که گزارش‌ها آن سطرها را به آن کاربر نسبت می‌دهند. قیف پیش از ورود دوخته نمی‌شود.

پس چه کنید: هرچه زودتر که مشروع است `identify` را صدا بزنید. اگر کاربر از نشست قبلی هنوز وارد است، در همان ابتدای بارگذاری صفحه یا راه‌اندازی اپ و پیش از هر رویداد دیگری `identify` را صدا بزنید، تا رویدادهای آن نشست از همان پیام اول `user_id` داشته باشند. رویدادی که با شناسه کاربر فرستاده شود، درست نسبت داده می‌شود؛ آنچه دوخته نمی‌شود فقط چیزی است که پیش از آن آمده.

## دو دستگاه، یک آدم {#two-devices}

دستگاه اول `anon_A` را می‌سازد و دستگاه دوم `anon_B`. هر دو `identify("u_1")` را صدا می‌زنند.

- دو پیام alias فرستاده می‌شود، یکی از هر دستگاه.
- دو سطر در نقشه هویت می‌نشیند، با کلیدهای `anon_A` و `anon_B` و هر دو با مقدار `u_1`. هر دو می‌مانند: موتور ReplacingMergeTree فقط سطرهایی را جمع می‌کند که شناسه مهمانشان یکی باشد.
- همه رویدادهایی که `user_id` برابر `u_1` دارند، از هر دو دستگاه، در یک سطر پرونده جمع می‌شوند. شمارنده‌ها روی هر دو دستگاه جمع می‌شوند.
- حقایق دستگاه، یعنی `device_type` و `os_name` و `app_version` و `push_provider` و `city` و `timezone` و `language`، آخرین نشست را توصیف می‌کنند نه اجتماع دو دستگاه را. فقط وقتی به‌روز می‌شوند که زمان رویداد از آخرین دیده‌شدن عقب‌تر نباشد، و مقدار خالی روی مقدار موجود نمی‌نشیند. رویدادی که از یک نخ پس‌زمینه بدون زمینه دستگاه فرستاده شود، نباید کاربری را که قابل دسترسی بود از دسترس خارج کند: آن آدم از هر کمپین پوشی می‌افتاد بیرون و هیچ لاگی دلیلش را نمی‌گفت.
- رویدادهای پیش از ورود هر دو دستگاه، بدون نسبت می‌مانند.
- پاک‌سازی این حالت را درست انجام می‌دهد: رویدادهای مهمان همه شناسه‌هایی را که نقشه به آن کاربر نسبت داده، پاک می‌کند.

## دو نفر، یک دستگاه {#two-people-one-device}

دستگاه `anon_X` را می‌سازد. نفر اول `identify("u_A")` را صدا می‌زند. بعد، **بدون اینکه `reset` صدا زده شود**، نفر دوم `identify("u_B")` را صدا می‌زند.

- شناسه کاربر ذخیره‌شده از `u_A` به `u_B` عوض می‌شود، پس شرط «فرق کرده» درست است و alias دومی به صف می‌رود.
- `anonymous_id` عوض نشده است، چون فقط `reset` شناسه مهمان تازه می‌سازد. پس alias دوم دوباره `previous_id` برابر `anon_X` دارد.
- نقشه هویت روی `(tenant_id, anonymous_id)` مرتب است، پس سطر دوم **جای سطر اول را می‌گیرد**. بعد از ادغام، `anon_X` به `u_B` اشاره می‌کند و این واقعیت که زمانی به `u_A` تعلق داشت، از بین رفته است.

> [!danger]
> نتیجه‌اش روی پاک‌سازی: اگر بعدا `u_A` بخواهد فراموش شود، جست‌وجو دیگر `anon_X` را پیدا نمی‌کند، پس رویدادهای پیش از ورود `u_A` پاک نمی‌شوند. این یک شکاف واقعی است، برگشت ندارد، و هیچ تستی آن را پوشش نمی‌دهد. تنها چیزی که جلویش را می‌گیرد، صدا زدن `reset` هنگام خروج است.

آنچه سالم می‌ماند: `u_A` و `u_B` دو پرونده جدا هستند و هرکدام شمارنده‌های خودش را نگه می‌دارد. رویدادهای واردشده هر دو هم درست نسبت داده می‌شوند، چون هر رویداد شناسه کاربری را حمل می‌کند که در لحظه به صف رفتن جاری بوده است.

## `reset` {#reset}

هنگام خروج صدایش بزنید. هر بار.

آنچه انجام می‌دهد:

1. شناسه کاربر پاک می‌شود، هم از حافظه و هم از انبار.
2. یک شناسه مهمان **تازه** ساخته و ذخیره می‌شود.
3. کلید نشست پاک می‌شود.
4. فقط در وب: انتساب کمپین ذخیره‌شده پاک می‌شود. روی یک کامپیوتر مشترک، خرید نفر بعدی نباید به حساب پیامی نوشته شود که نفر قبلی گرفته بود. اندروید بازپخش کمپین سمت کلاینت ندارد، پس چیزی برای پاک‌کردن ندارد.

آنچه انجام **نمی‌دهد**، در هر سه SDK:

- صف را خالی نمی‌کند و چیزی نمی‌فرستد. پیام‌های بافرشده هنوز با شناسه کاربر همان لحظه‌ای که ساخته شدند بیرون می‌روند، که همان رفتار درست است.
- ویژگی‌های کش‌شده محلی را پاک نمی‌کند. یعنی تا اولین `identify` بعدی، قاعده‌های هدف‌گیری پیام درون‌برنامه‌ای هنوز روی ویژگی‌های نفر قبلی شرط می‌گذارند.
- پرچم «قبلا اینجا بوده» را پاک نمی‌کند، عمدا. خروج از حساب کسی را کاربر تازه نمی‌کند، و کمپین «اولین اجرا» که بعد از هر خروج دوباره ظاهر شود، باگی است که مشتری آن را از زبان کاربرانش می‌شنود.
- هیچ‌چیزی به سرور نمی‌فرستد. **در سمت سرور چیزی به نام reset وجود ندارد**: فقط پنج نوع پیام پذیرفته می‌شود و هیچ عملیات جدا کردن یا لغو پیوند وجود ندارد.

`reset` کنترل رضایت نیست. برای توقف جمع‌آوری `optOut` هست، که جمع‌آوری را متوقف می‌کند **و بافر را خالی می‌کند**، چون احترام‌گذاشتن به انصراف فقط برای رویدادهای آینده، در حالی که آنچه قبلا ضبط شده بی‌سروصدا تحویل داده می‌شود، انصراف نیست.

## `user_hash` و اینکه به چه درد می‌خورد {#user-hash}

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

صندوق پیام درون‌برنامه‌ای اولین **خواندن** است. سطرهایش متن پیام و کد تخفیف شخصی‌سازی‌شده‌ای را دارند که برای یک مشتری مشخص ساخته شده است، و «صندوق کاربر ۹۱۳۷۲ را به من بده» پشت کلیدی که همه می‌توانند بخوانند، endpointای است که نمی‌تواند وجود داشته باشد.

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

فرمول، دقیقا:

```text
user_hash = hex(hmac_sha256(identity_secret, user_id))
```

`identity_secret` مال تنانت شماست و **هرگز نباید به مرورگر برسد**. بک‌اند شما هنگام ورود هش را حساب می‌کند و به SDK می‌دهد، و SDK آن را با هر درخواست صندوق پیام می‌فرستد.

گرفتنش خودسرویس نیست و این را باید در برنامه‌ریزی حساب کنید: هیچ مسیر HTTPای این رمز را نمی‌نویسد و هیچ صفحه‌ای در پنل برایش وجود ندارد. تنها چیزی که آن را روی تنانت می‌گذارد ابزار خط‌فرمان `adminctl` است، که یعنی یک اپراتور سگمنتیک. تا وقتی آن تماس انجام نشده، صندوق پیام درون‌برنامه‌ای برای شما فقط ۴۰۳ برمی‌گرداند.

```js title="سمت سرور شما، هنگام ورود"
import { createHmac } from "node:crypto";

export function userHashFor(userId) {
  return createHmac("sha256", process.env.SEGMENTIC_IDENTITY_SECRET)
    .update(userId)
    .digest("hex");
}
```

```bash title="خواندن صندوق پیام"
curl -X POST https://in.segmentic.net/v1/inbox \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer wk_seg_..." \
  -d '{
    "user_id": "u_88123",
    "user_hash": "3f6c1d0a9b8e4725c0d1e2f3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7",
    "limit": 20
  }'
```

```json title="پاسخ وقتی پیامی نیست"
{"status":"ok","messages":[]}
```

```json title="پاسخ وقتی هش غلط یا نیامده باشد"
{"status":"error","message":"user identity is not verified"}
```

چند رفتار دقیق که باید بدانید:

- فقط دو endpoint این هش را می‌خواهند: `POST /v1/inbox` و `POST /v1/inbox/ack`. بقیه مسیرهای کلکتور نوشتن‌اند و کلید نوشتن برایشان کافی است.
- بسته می‌ماند اگر تنظیم نشده باشد، ولی نه به آن شکلی که انتظار دارید. ثبت این دو مسیر تصمیم کل نصب است نه تصمیم تنانت: روی هر نصبی که صندوق پیام پیکربندی شده باشد، هر دو مسیر برای همه وجود دارند. آنچه یک تنانت بدون رمز هویت می‌گیرد، ۴۰۳ روی هر درخواست است. حالت «تاییدنشده» وجود ندارد.
- اگر `user_id` در بدنه نباشد، پیش از هر بررسی هویتی کد ۴۰۰ با پیام `user_id is required` برمی‌گردد.
- مقایسه زمان‌ثابت است. این endpoint رو به دنیاست و مقایسه بایت‌به‌بایت مقدار درست را برای آدم صبور، حرف‌به‌حرف لو می‌دهد.
- هش با حروف بزرگ هم پذیرفته می‌شود؛ پیش از مقایسه فاصله‌هایش بریده و کوچک می‌شود.
- هش غلط و هش نیامده، هر دو کد ۴۰۳ با همان یک بدنه می‌گیرند. جدا کردنشان این را به ابزاری تبدیل می‌کرد که بشود با آن فهمید کدام شناسه‌های کاربر وجود دارند.
- چرخاندن رمز، هر هشی را که بک‌اند شما قبلا داده باطل می‌کند، یعنی کل اپ شما تا زمان استقرار بعدی از صندوق پیامش بیرون می‌افتد. کاری نیست که تصادفی انجام شود.

## هویت وقتی از سرور خودتان می‌فرستید {#server-side}

از بک‌اند خودتان، رویدادها به `POST /v1/events` روی `api.segmentic.net` می‌روند، با یک کلید API که دسترسی `profile.write` دارد. پاسخ موفق کد ۲۰۲ است، نه ۲۰۰: رویدادها در صف نشسته‌اند نه ذخیره‌شده، و چند ثانیه بعد قابل پرس‌وجو می‌شوند. اگر ۲۰۰ می‌گفتیم، فراخواننده وسوسه می‌شد بلافاصله آن‌ها را بخواند و نتیجه بگیرد که گم شده‌اند.

چرا `profile.write` و نه یک دسترسی جدید: کاری که این می‌کند همین است، روی پرونده آدم‌ها و تاریخچه رویدادشان می‌نویسد، و اختراع اسم دوم برای یک توانایی، به کسی اجازه می‌داد یکی را بدهد و باور کند دیگری را نگه داشته است.

```bash title="رویداد از سمت سرور"
curl -X POST https://api.segmentic.net/v1/events \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk_seg_..." \
  -d '{
    "events": [
      {
        "type": "track",
        "user_id": "u_88123",
        "event": "order_completed",
        "timestamp": "2026-08-07T10:02:41.000Z",
        "properties": { "order_id": "A-100294", "revenue": 2450000, "currency": "IRR" }
      }
    ]
  }'
```

```json title="پاسخ، با کد ۲۰۲"
{"accepted":1}
```

سه چیز که این مسیر با هویت فرق دارد:

- شما تقریبا همیشه `user_id` را دارید، پس همان را بفرستید و اصلا سراغ `anonymous_id` نروید.
- این مسیر بدون نشانی آی‌پی و بدون User-Agent نرمال می‌شود، چون تماس سرور به سرور است و نسبت‌دادن شهر گیرنده از روی نشانی دیتاسنتر شما، همه کاربرانتان را در یک نقطه می‌نشاند. یعنی روی این مسیر خبری از موقعیت جغرافیایی، مرورگر، پرچم ربات و تشخیص دستگاه نیست.
- سرور شما شناسه مهمان مرورگر را نمی‌داند و نمی‌تواند بداند. اگر می‌خواهید نشست مهمان و رویدادهای سرور به هم برسند، alias باید از سمت مرورگر برود، یعنی از `identify` که SDK صدا می‌زند.

و سه چیز که هویت نیستند ولی اگر ندانیدشان همین‌جا گیر می‌کنید، چون هر سه بی‌صدا هستند:

- **این مسیر حذف تکراری ندارد.** `message_id` اینجا از شما محافظت نمی‌کند. همان دسته را دو بار بفرستید و دو بار نوشته می‌شود. حذف تکراری ۴۸ ساعته کار کلکتور است و این در، از کنارش رد می‌شود.
- **هشدارها حساب می‌شوند و دور ریخته می‌شوند.** بدنه پاسخ فقط `accepted` و در صورت لزوم `rejected` دارد. پس `invalid_phone` یا `generated_message_id` را از این مسیر هرگز نمی‌بینید، حتی وقتی رخ داده‌اند.
- **پنجره زمانی اینجا ثابت ۳۰ روز است**، نه سیاست نگهداری تنانت. هر `timestamp` قدیمی‌تر بی‌صدا به لبه پنجره چسبانده می‌شود و پاسخ همان ۲۰۲ موفق است. برای همین است که مهاجرت تاریخچه از این در انجام نمی‌شود.

## قاعده‌هایی که رعایتشان بعدا وقت شما را می‌خرد {#rules}

| قاعده | چه چیزی را جلوگیری می‌کند |
|---|---|
| `identify` را در همان اولین لحظه‌ای که می‌دانید کاربر کیست صدا بزنید، و در هر بارگذاری صفحه یا راه‌اندازی اپ برای کاربری که هنوز وارد است | تاریخچه پیش از ورود دوخته نمی‌شود، پس هرچه دیرتر صدا بزنید، بیشتر از داده‌تان بی‌صاحب می‌ماند |
| `reset` را هنگام خروج صدا بزنید، بدون استثنا | نفر بعدی روی همان دستگاه شناسه مهمان نفر قبلی را به ارث می‌برد، و پیوند نفر قبلی برای همیشه پاک می‌شود |
| برای `user_id` کلید اصلی خودتان را بگذارید، نه ایمیل و نه شماره تلفن | `user_id` کلید اصلی پرونده است و هیچ عملیات تغییر نامی وجود ندارد. آدمی که ایمیلش را عوض کند، پرونده دوم می‌گیرد |
| هرگز مقداری بگذارید که در هر نشست عوض شود | به ازای هر نشست یک پرونده ساخته می‌شود و شمارش کاربران بی‌معنی می‌شود |
| پیش از اینکه دو سیستم شروع به نوشتن کنند، سر یک قالب `user_id` توافق کنید | هیچ عملیاتی برای ادغام دو پرونده وجود ندارد. `alias` هم این کار را نمی‌کند |
| ایمیل و تلفن را به‌عنوان ویژگی پرونده بفرستید، نه به‌عنوان `user_id` | ویژگی جای درست آن‌هاست و آنجا نرمال‌سازی می‌شوند؛ `user_id` در خروجی‌ها و در نشانی صفحه تایم‌لاین ظاهر می‌شود |

قدم بعد: [تعریف رویداد](/docs/events) می‌گوید چه چیزی بفرستید و با چه نامی، و [فرهنگ‌نامه رویدادها](/docs/event-dictionary#traits) فهرست ویژگی‌های پرونده را دارد که با `identify` می‌فرستید.

---

# SDK وب

> نصب روی سایت، همهٔ متدها، تنظیمات، صف آفلاین، و اعلان مرورگر.
> https://segmentic.net/docs/sdk-web

SDK وب رویدادها را در مرورگر جمع می‌کند، تا وقتی شبکه برنگشته روی `localStorage` نگه می‌دارد، و هر پیام را با یک شناسه ثابت می‌فرستد تا ارسال دوباره روی شبکه بی‌کیفیت، خرید کسی را دو بار نشمارد. همان فایل، اعلان مرورگر و پیام‌های درون‌سایت را هم راه می‌اندازد.

کتابخانه در هر پیام خودش را با `library.name = "segmentic-js"` و `library.version = "0.1.0"` معرفی می‌کند.

## نصب {#install}

باندل از همان مبدائی سرو می‌شود که رویدادها به آن پست می‌شوند. این عمدی است: یک ورودی در Content-Security-Policy مشتری لازم می‌شود، نه دو تا.

```text
https://in.segmentic.net/sdk/segmentic.js
```

### با تگ اسکریپت {#script-tag}

این همان کدی است که صفحه «اتصال» در پنل تولید می‌کند. کلید نوشتن (`wk_seg_...`) عمومی است و قرار است در سورس صفحه دیده شود.

```html
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net"
  });
  Segmentic.page();
</script>
```

روی توسعه لوکال، باندل را دشبورد از پوشه `public` خودش سرو می‌کند و کالکتور جای دیگری است:

```html
<script src="http://localhost:3000/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "http://localhost:8080"
  });
  Segmentic.page();
</script>
```

> [!warn]
> نسخه‌ای از این آدرس وجود ندارد. مسیر `/sdk/segmentic.js` همیشه ساخت جاری را می‌دهد و هیچ آدرس نسخه‌داری برای پین‌کردن منتشر نشده است. هش `integrity` هم منتشر نشده، پس اگر سیاست امنیتی شما Subresource Integrity اجباری دارد، باید خودتان فایل را میزبانی کنید و هش را خودتان بسازید.

### با باندلر {#bundler}

**بسته npm وجود ندارد.** نام `@segmentic/web` در `package.json` مخزن هست، ولی روی هیچ رجیستری‌ای منتشر نشده است، هیچ مرحله‌ای در CI آن را بیلد یا منتشر نمی‌کند، و `npm install @segmentic/web` شکست می‌خورد.

تب npm در صفحه «اتصال» پنل هنوز همان `import segmentic from "@segmentic/web"` را نشان می‌دهد. آن قطعه کد امروز اجرا نمی‌شود. تا وقتی بسته منتشر نشده، تنها مسیر پشتیبانی‌شده تگ اسکریپت است.

اگر پروژه شما ناچار است ماژول import کند، فایل ESM را از همان باندل بگیرید و کنار کد خودتان بگذارید. تعریف تایپ‌ها (`index.d.ts`) روی هیچ آدرس عمومی‌ای سرو نمی‌شود، پس با این مسیر TypeScript تایپ نمی‌گیرد.

## راه‌اندازی و همه گزینه‌ها {#init}

`writeKey` و `apiHost` اجباری‌اند و نبودشان استثنا پرتاب می‌کند: `segmentic: writeKey is required` و `segmentic: apiHost is required`. این تنها جایی است که SDK خطا پرتاب می‌کند. اسلش‌های انتهایی `apiHost` حذف می‌شوند.

| گزینه | نوع | پیش‌فرض | کاری که می‌کند |
|---|---|---|---|
| `writeKey` | `string` | ندارد، اجباری | کلید نوشتن از پنل. عمومی است |
| `apiHost` | `string` | ندارد، اجباری | آدرس پایه کالکتور |
| `batchSize` | `number` | `20` | وقتی این تعداد پیام در صف جمع شد، بی‌درنگ ارسال کن |
| `flushInterval` | `number` میلی‌ثانیه | `10000` | حداقل هر این مقدار یک بار ارسال کن |
| `maxQueueSize` | `number` | `500` | چند پیام حق دارند آفلاین روی دیسک منتظر بمانند |
| `maxRetries` | `number` | `10` | سقف رشد فاصله تلاش دوباره. سقف نگهداری داده نیست |
| `autoContext` | `boolean` | `true` | جمع‌کردن خودکار صفحه، زبان، صفحه‌نمایش و منطقه زمانی |
| `autoPageView` | `boolean` | `true` | یک پیام `page` هنگام `init()` بفرست |
| `respectDoNotTrack` | `boolean` | `true` | تنظیم Do Not Track مرورگر را رعایت کن |
| `onsite` | `boolean` | `true` | کمپین‌های درون‌سایت را بگیر و بکش |
| `debug` | `boolean` | `false` | فعالیت SDK را با پیشوند `[segmentic]` در کنسول بنویس |
| `sessionTimeout` | `number` میلی‌ثانیه | `1800000` | فاصله بی‌کاری که بعدش شناسه نشست عوض می‌شود |
| `now` | `() => number` | `() => Date.now()` | منبع زمان. برای تست |
| `fetchImpl` | `typeof fetch` | `globalThis.fetch` | پیاده‌سازی fetch. برای تست |

`onsite` از قصد روشن است: کمپین درون‌سایت با دست منتشر می‌شود، و مشتری‌ای که یکی منتشر کند و روی سایتش چیزی نبیند هیچ راهی ندارد بفهمد نصب خراب است یا فهرست کمپین‌ها خالی.

`init()` به ترتیب این کارها را می‌کند: ساخت انبار و آزمایش `localStorage` با یک نوشتن واقعی، خواندن صف از دیسک، خواندن یا شروع نشست، خواندن وضعیت انصراف و Do Not Track، خواندن یا ساختن شناسه ناشناس، نصب قلاب‌های چرخه عمر، زمان‌بندی ارسال دوره‌ای، ثبت کلیک کمپین، سپس `page()` اگر `autoPageView` روشن باشد، سپس یک `flush()` فوری برای تخلیه هرچه آفلاین جمع شده، و در آخر شروع درون‌سایت.

ثبت کلیک کمپین **قبل** از بازدید صفحه اجرا می‌شود تا کلیک اولین چیز ثبت‌شده باشد و بازدید صفحه بعدی خودش انتساب را همراه داشته باشد.

صدا زدن دوباره `init()` اول کلاینت قبلی را با `close()` می‌بندد.

> [!warn]
> `autoPageView` فقط یک بار، داخل `init()`، بازدید صفحه می‌فرستد. SDK هیچ قلابی روی `pushState`، `replaceState` یا `popstate` نصب نمی‌کند. توضیح تایپ `Options` می‌گوید «on init and on history navigation» و این نیمه دومش در کد وجود ندارد. در یک اپ تک‌صفحه‌ای بعد از هر تغییر مسیر خودتان `Segmentic.page()` را صدا بزنید.

## متدها {#methods}

```ts
init(options: Options): SegmenticClient
track(event: string, properties?: Properties, context?: Context): void
identify(userId: string, traits?: Traits, context?: Context): void
page(name?: string, properties?: Properties, context?: Context): void
screen(name: string, properties?: Properties, context?: Context): void
alias(previousId: string, context?: Context): void
reset(): void
flush(): Promise<void>
optOut(): void
optIn(): void
isOptedOut(): boolean
getAnonymousId(): string | null
getUserId(): string | null
stats(): Stats | null
subscribeToPush(options: PushOptions): Promise<PushResult>
unsubscribeFromPush(): Promise<boolean>
pushPermission(): NotificationPermission | "unsupported"
```

`track` با نام رویداد خالی، کاری نمی‌کند و فقط در کنسول لاگ می‌گذارد. همین‌طور `identify` با شناسه کاربر خالی. هیچ‌کدام خطا پرتاب نمی‌کنند، چون یک فراخوانی آنالیتیکس هرگز نباید چیزی باشد که صفحه پرداخت مشتری را می‌شکند. یازده متد از این‌ها اگر **قبل** از `init()` صدا زده شوند، `[segmentic] <method>() called before init(); ignoring` را در کنسول می‌نویسند و برمی‌گردند: `track`، `identify`، `page`، `screen`، `alias`، `reset`، `flush`، `optOut`، `optIn`، `subscribeToPush` و `unsubscribeFromPush`. پنج متدی که فقط یک مقدار می‌خوانند، قبل از `init()` هیچ صدایی ندارند: `isOptedOut()` مقدار `false` می‌دهد، `getAnonymousId()` و `getUserId()` و `stats()` مقدار `null` می‌دهند، و `pushPermission()` مقدار `"unsupported"` می‌دهد. آن `false` از همه مهم‌تر است، چون «انصراف نداده» خوانده می‌شود و هیچ هشداری نمی‌گوید که SDK اصلا راه نیفتاده بود.

`identify` سه کار دیگر هم می‌کند. اول، اگر این اولین شناسایی بعد از مرور ناشناس باشد و شناسه با قبلی فرق کند، یک پیام `alias` **قبل** از `identify` در صف می‌گذارد؛ بدون آن کل تاریخچه پیش از ورود یتیم می‌شود و هر قیفی که از مرز ورود رد شود عدد اشتباه گزارش می‌دهد. دوم، ویژگی‌های اسکالر را در `localStorage` مسطح می‌کند تا تارگتینگ درون‌سایت بتواند رویشان شرط بگذارد؛ هر مقدار از نوع `object` و هر `null` و `undefined` کنار گذاشته می‌شود و بقیه با `String()` به رشته تبدیل می‌شوند. سوم، `refreshOnsite()` را صدا می‌زند، چون بازدیدکننده واردشده ممکن است حالا با کمپینی جور دربیاید که ناشناس درنمی‌آمد.

`page(name)` نام را هم به عنوان `event` می‌فرستد و هم داخل `properties.name` کپی می‌کند.

`screen(name)` پیام نوع `screen` می‌فرستد. برای وب `page()` درست است؛ `screen` برای هم‌شکل‌ماندن با SDKهای موبایل وجود دارد.

`reset()` شناسه کاربر را پاک می‌کند، یک شناسه ناشناس **تازه** می‌سازد، نشست را می‌اندازد و انتساب کمپین ذخیره‌شده را هم دور می‌ریزد (روی کامپیوتر مشترک، خرید نفر بعدی نباید به پیامی که نفر قبلی گرفته نسبت داده شود). پرچم «پیش‌تر اینجا بوده» را از قصد پاک نمی‌کند: خروج از حساب، کسی را بازدیدکننده تازه نمی‌کند.

`flush()` وقتی resolve می‌شود که تلاش تمام شده باشد، نه وقتی چیزی رفته باشد: اگر هنوز داخل پنجره عقب‌نشینی باشیم، بدون هیچ درخواستی برمی‌گردد. فراخوانی‌های هم‌زمان زنجیر می‌شوند نه ادغام؛ رویدادی که بعد از خالی‌شدن صف و پیش از settle شدن آن پاس اضافه شود، وگرنه «تحویل‌شده» گزارش می‌شد در حالی که هنوز روی دیسک است.

`stats()` این شکل را برمی‌گرداند:

```ts
{
  queued: number;          // چند پیام همین حالا در صف است
  sent: number;            // چند پیام از init تا حالا پذیرفته شده
  dropped: number;         // چند پیام دور ریخته شده
  failures: number;        // شمار شکست‌های پشت‌سرهم فعلی
  optedOut: boolean;
  durableStorage: boolean; // false یعنی حافظه موقت، صف از reload جان به در نمی‌برد
  anonymousId: string;
  userId: string | null;
}
```

### گلوبال در برابر نمونه {#surfaces}

دو سطح وجود دارد و یکی نیستند.

`window.Segmentic` فضای نام ماژول است. هفده متد بالا رویش هست، به‌علاوه `SegmenticClient`، `pushSupported`، `decodeVapidKey`، ارزیاب تارگتینگ درون‌سایت (`eligible`، `matches`، `maySee`، `isLive`، `deviceOf`، `readSeen`، `writeSeen`، `recordSeen`، `recordAction`) و کلید `default`.

نمونه‌ای که `init()` برمی‌گرداند سه متد دارد که **روی گلوبال نیستند**:

```ts
close(): void                          // تایمرها و شنونده‌ها را متوقف می‌کند
onsiteCampaigns(): OnsiteCampaign[]     // آخرین فهرست کمپین‌های گرفته‌شده
refreshOnsite(): void                   // دوباره تصمیم بگیر کدام کمپین را نشان بدهی
```

برای رسیدن به این سه تا باید خروجی `init()` را نگه دارید:

```js
const segmentic = Segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net"
});

// بعد از هر تغییر مسیر در یک اپ تک‌صفحه‌ای
router.afterEach(() => {
  segmentic.page();
  segmentic.refreshOnsite();
});
```

## چیزی که روی سیم می‌رود {#wire-format}

هر پیام این شکل را دارد. فیلدهای خالی حذف می‌شوند، `null` فرستاده نمی‌شود.

```ts
{
  type: "track" | "identify" | "page" | "screen" | "alias";
  message_id: string;      // همیشه هست
  timestamp: string;       // ISO 8601، همیشه هست
  sent_at?: string;        // لحظه ارسال، در زمان ارسال مهر می‌خورد
  event?: string;
  user_id?: string;
  anonymous_id?: string;
  previous_id?: string;
  properties?: Properties;
  traits?: Traits;
  context?: Context;
}
```

بدنه‌ای که به `POST {apiHost}/v1/batch` می‌رود، پیام‌ها را داخل `batch` می‌گذارد و `sent_at` را **هم روی پاکت و هم روی تک‌تک پیام‌ها** می‌نویسد:

```bash
curl -X POST https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sent_at": "2026-07-30T12:00:00.000Z",
    "batch": [
      {
        "type": "track",
        "message_id": "0f9c6f1e-3d4a-4a1e-9c5b-2b7a1f6d8e30",
        "timestamp": "2026-07-30T12:00:00.000Z",
        "sent_at": "2026-07-30T12:00:00.000Z",
        "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
        "event": "order_completed",
        "properties": { "revenue": 2500000, "currency": "IRR", "city": "تهران" },
        "context": {
          "library": { "name": "segmentic-js", "version": "0.1.0" },
          "session_id": "2a3c1d55-77b0-4c8e-9a1f-0e6d4b3a2c19",
          "locale": "fa-IR",
          "timezone": "Asia/Tehran",
          "page": {
            "url": "https://shop.example.com/checkout/done",
            "path": "/checkout/done",
            "search": "",
            "title": "سفارش ثبت شد",
            "referrer": "https://shop.example.com/cart"
          }
        }
      }
    ]
  }'
```

پاسخ بسته‌ای که تمامش پذیرفته شده:

```json
{ "status": "ok", "accepted": 1 }
```

پاسخ وقتی یک قلم از سه قلم بد است. بقیه پذیرفته می‌شوند و کالکتور می‌گوید کدام ایندکس رد شده است:

```json
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [{ "index": 1, "reason": "missing_identity" }]
}
```

هدرها: `Content-Type: application/json` و `Authorization: Bearer {writeKey}`. پرچم `keepalive` فقط وقتی روشن می‌شود که طول بدنه کمتر از ۶۰۰۰۰ بایت باشد.

سقف‌های سمت سرور: حداکثر ۵۰۰ قلم در هر بسته و حداکثر پنج مگابایت بدنه. با پیش‌فرض `batchSize` برابر ۲۰ به هیچ‌کدام نزدیک نمی‌شوید.

### کانتکست خودکار {#context}

با `autoContext: true` این‌ها روی هر پیام می‌روند:

- `library` با نام و نسخه کتابخانه. همیشه، و **قابل بازنویسی نیست**: کانتکستی که خودتان پاس می‌دهید روی بقیه فیلدها می‌نشیند ولی `library` به مال ما برمی‌گردد
- `session_id`، همیشه
- `locale` از `navigator.language`
- `timezone` از `Intl.DateTimeFormat().resolvedOptions().timeZone`، داخل try و catch، چون Intl روی بعضی مرورگرهای توکار نیست
- `screen` با `width`، `height` و `density`
- `page` با `url`، `path`، `search`، `title` و `referrer`
- `network` با `cellular` و `wifi`، فقط وقتی `navigator.connection.type` وجود داشته باشد

`device` و `os` از قصد فرستاده نمی‌شوند. کالکتور آن‌ها را از هدر User-Agent درمی‌آورد که کلاینت نمی‌تواند جعلش کند؛ هرچه از اینجا برود یک حدس است نه یک واقعیت. IP هم از خود اتصال برداشته می‌شود، نه از بدنه.

با `autoContext: false` فقط `library` و `session_id` می‌روند. انتساب کمپین در این حالت هم می‌رود، چون جوابی است که خود مشتری خواسته، نه چیزی که ما درباره بازدیدکننده جمع کرده‌ایم.

کانتکست هر فراخوانی روی کانتکست جمع‌شده می‌نشیند و زیرشاخه‌های `app`، `page` و `campaign` سطحی ادغام می‌شوند:

```js
Segmentic.track("video_played", { id: 42 }, {
  app: { name: "shop-web", version: "5.2.1" }
});
```

### انتساب کمپین {#attribution}

این پارامترهای آدرس خوانده می‌شوند:

| پارامتر آدرس | می‌رود در `context.campaign.` |
|---|---|
| `utm_source` | `source` |
| `utm_medium` | `medium` |
| `utm_campaign` | `name` |
| `utm_term` | `term` |
| `utm_content` | `content` |
| `sg_mid` | `message_id` |
| `sg_t` | `token` |
| `sg_cid` | `campaign_id`، فقط اگر عدد متناهی و بزرگ‌تر از صفر باشد |

انتساب فقط با `sg_mid` شروع می‌شود. آدرسی که فقط UTM دارد هیچ کلیکی ثبت نمی‌کند.

وقتی `sg_mid` تازه‌ای دیده شود، SDK یک پیام `track` با نام رویداد `message_clicked` در صف می‌گذارد، **یک بار، تا وقتی شناسه پیام دیگری جایش را بگیرد**. رفرش، دکمه بازگشت، یا لینکی که بین همکارها دست به دست شده، کلیک دوم حساب نمی‌شود. مقایسه با همان یک شناسه داخل حافظه است نه با تاریخچه‌ای از شناسه‌ها، پس بازدیدکننده‌ای که روی یک پیام بیاید، بعد روی پیام دوم، و بعد دوباره روی همان پیام اول، کلیک پیام اول را دو بار گزارش می‌کند.

کمپین گرفته‌شده در `localStorage` با یک زمان ذخیره می‌شود و **روی هر رویداد بعدی تا هفت روز پخش می‌شود**. هفت روز درست برابر پنجره سرور است؛ دو پنجره متفاوت یعنی SDK تبدیل‌هایی بفرستد که سرور بی‌صدا دور می‌ریزد.

چون از حافظه پخش می‌شود نه از نوار آدرس، تمیزکردن آدرس با `replaceState` انتساب را از بین نمی‌برد. اگر خواندن مستقیم از query انجام می‌شد، در یک اپ تک‌صفحه‌ای پارامترها می‌ماندند و کل بازدید به آن پیام نسبت داده می‌شد، و در سایتی که آدرسش را تمیز می‌کند اعتبار یک کلیک بعد از بین می‌رفت. هر دو غلط‌اند و هیچ‌کدام در تست دیده نمی‌شود.

یک `sg_mid` تازه‌تر جای قبلی را می‌گیرد و کلیک دوم را گزارش می‌کند. `reset()` انتساب را می‌اندازد. بازدیدکننده‌ای که انصراف داده هیچ‌چیز ثبت نمی‌کند، حتی کلیک کمپین.

SDK هرگز به `sg_t` نگاه نمی‌کند. شناسه پیام مشتق‌شده و حدس‌زدنی است و آن توکن چیزی است که کلیک واقعی را از چیزی که هر کسی می‌تواند در نوار آدرس تایپ کند جدا می‌کند؛ کار SDK فقط برگرداندن آن است.

### نشست {#sessions}

یک `session_id` به ازای هر بازدید. شناسه بعد از `sessionTimeout` بی‌کاری عوض می‌شود، نه با بارگذاری صفحه، تا کسی که ده دقیقه مقاله می‌خواند و بعد کلیک می‌کند در همان نشست بماند. نشست منقضی‌شده در بارگذاری بعدی زنده نمی‌شود.

## صف آفلاین {#queue}

هر پیام **قبل از هر تلاش شبکه‌ای** روی `localStorage` نوشته می‌شود، زیر کلید `segmentic_queue`. بستن تب وسط درخواست، قطع‌شدن آنتن، یا قطعی سمت ما هیچ هزینه‌ای ندارد.

`localStorage` با یک نوشتن واقعی (کلید `__segmentic_probe__`) آزمایش می‌شود، نه با اعتماد به اینکه شیئش وجود دارد. در حالت خصوصی سافاری، مرورگرهای سازمانی سخت‌گیر و وقتی سهمیه مبدأ پر شده، این نوشتن خطا می‌دهد و SDK به یک انبار حافظه‌ای برمی‌گردد که دست‌کم صفحه جاری را کار می‌اندازد. `stats().durableStorage` می‌گوید کدام یکی فعال است.

ارسال در دو حالت اتفاق می‌افتد: وقتی طول صف به `batchSize` برسد، و هر `flushInterval`. رویداد `online` مرورگر هم یک ارسال راه می‌اندازد. تخلیه حلقه می‌زند و هر بار `batchSize` قلم برمی‌دارد تا صف خالی شود؛ با `batchSize: 2` و پنج رویداد، اندازه بسته‌ها ۲ و ۲ و ۱ می‌شود.

صف خراب هزینه‌ای ندارد. JSON بی‌ریخت باعث می‌شود کلید حذف و صف خالی برگردانده شود، و قلم‌هایی که شکل پیام ندارند (بدون `message_id` رشته‌ای یا بدون `type` رشته‌ای) تک‌تک فیلتر می‌شوند نه اینکه کل بافر دور ریخته شود.

### چه چیزی حذف می‌شود و چطور گزارش می‌شود {#drops}

سه دلیل حذف وجود دارد. همه‌شان در `stats().dropped` جمع می‌شوند و با `debug: true` در کنسول به شکل `[segmentic] dropped <n> messages: <reason>` نوشته می‌شوند.

| دلیل | چه وقت |
|---|---|
| `queue_full` | طول صف از `maxQueueSize` رد شده است |
| `storage_quota` | نوشتن روی دیسک شکست خورد، نیمی از بافر ریخته شد و دوباره تلاش شد |
| `storage_unavailable` | نوشتن دوم هم شکست خورد. کار در حافظه ادامه پیدا می‌کند و آنچه مانده با بستن تب می‌رود |

سرریز **از جلوی صف** حذف می‌کند، یعنی قدیمی‌ترین‌ها. بعد از یک قطعی طولانی، تازه‌ترین رویدادها آن‌هایی هستند که هنوز ارزش داشتن دارند. با `maxQueueSize: 10` و بیست‌وپنج رویداد، ده تای آخر می‌مانند و پانزده تا در `dropped` شمرده می‌شوند.

یک راه حذف چهارم هم هست که در `dropped` می‌آید و از صف نمی‌آید: پاسخ `4xx` سرور، که پایین‌تر توضیح داده شده.

### تلاش دوباره و کدهای وضعیت {#retries}

| پاسخ | رفتار |
|---|---|
| `2xx` | تایید شد، از صف پاک می‌شود، شمارنده شکست صفر می‌شود |
| `4xx` به‌جز `429` | **برای همیشه دور ریخته می‌شود.** از صف پاک می‌شود، در `dropped` شمرده می‌شود، و با `debug` این خط لاگ می‌شود: `server rejected batch permanently: <status>` |
| `429` | در صف می‌ماند و دوباره تلاش می‌شود |
| `5xx` | در صف می‌ماند، عقب‌نشینی اعمال می‌شود |
| خطای شبکه، DNS یا CORS | گذرا فرض می‌شود، در صف می‌ماند |

دلیل رفتار با `4xx`: پاسخ `4xx` یعنی محتوا غلط است و هرگز پذیرفته نخواهد شد. تلاش بی‌پایان روی آن، هر رویداد بعدی را هم پشتش قفل می‌کند. پس دور ریخته می‌شود، ولی با صدا.

همین منطق سمت سرور هم رعایت شده: وقتی خود جست‌وجوی کلید نوشتن ناموفق باشد، کالکتور `503` با `Retry-After: 5` می‌دهد و نه `401`. یک SDK پاسخ `401` را «این کلید هرگز کار نخواهد کرد» می‌خواند و رویدادها را دور می‌ریزد؛ `503` را «بعد دوباره امتحان کن» می‌خواند و نگهشان می‌دارد.

عقب‌نشینی نمایی با jitter کامل است: تاخیر یک عدد تصادفی یکنواخت بین صفر و `min(300000, 1000 * 2^n)` میلی‌ثانیه، که `n` شمار شکست‌های پشت‌سرهم است. یعنی سقف پنج دقیقه. jitter از خود منحنی مهم‌تر است: وقتی یک بک‌اند برمی‌گردد، هزاران دستگاهی که هم‌زمان شکست خورده‌اند نباید هم‌زمان دوباره تلاش کنند و دوباره بیندازندش.

`maxRetries` فقط سقف رشد این فاصله است، **نه سقف نگهداری داده**. وقتی شمار شکست‌ها به آن برسد، این خط لاگ می‌شود: `max retries reached; messages stay queued for the next session`. پیام‌ها می‌مانند. کاربر ممکن است فقط در قطار باشد.

### یکتاسازی با message_id {#dedup}

`message_id` یک UUID است که **یک بار**، موقع اضافه‌شدن به صف، ساخته می‌شود و روی هر تلاش دوباره همان می‌ماند. کل مبنای یکتاسازی همین است: روی شبکه موبایل بی‌کیفیت SDK دوباره می‌فرستد، و بدون شناسه ثابت، شمار خریدهای مشتری بی‌صدا دو برابر می‌شود.

شناسه از `crypto.randomUUID()` می‌آید، وگرنه از `crypto.getRandomValues()` با بیت‌های نسخه چهار `RFC 4122` که دستی ست می‌شوند، و در آخر برای مرورگرهای خیلی قدیمی از `Math.random()`. مسیر آخر ضعیف‌تر است، ولی یک شناسه تصادم‌کرده فقط یک رویداد یکتاشده هزینه دارد در حالی که خطا پرتاب‌کردن آنجا همه رویدادها را هزینه می‌کند.

سمت سرور، کالکتور شناسه‌های دیده‌شده را نگه می‌دارد. مدت نگهداری از `DEDUPE_TTL` می‌آید و پیش‌فرضش ۴۸ ساعت است. تکراری‌ها برای فرستنده «پذیرفته‌شده» شمرده می‌شوند، چون SDK یک بار تحویلشان داده و باید دست از تلاش بردارد.

### تصحیح ساعت دستگاه {#clock-skew}

هر بسته `sent_at` را روی پاکت و روی تک‌تک پیام‌ها می‌گذارد. کالکتور اختلاف بین زمان دریافت خودش و آن `sent_at` را حساب می‌کند و همان اختلاف را روی `timestamp` رویداد اعمال می‌کند. ساعتی که غلط ولی یکنواخت است، این‌طوری بازیابی می‌شود.

جزئیاتی که اهمیت دارند:

- اختلاف کمتر از یک دقیقه نادیده گرفته می‌شود
- تصحیح فقط وقتی اعمال می‌شود که زمان اصلاح‌شده داخل پنجره مجاز بماند؛ وگرنه زمان اصلی نگه داشته می‌شود
- زمانی که بیش از یک ساعت جلوتر از سرور باشد به زمان دریافت گیره می‌شود و هشدار `timestamp_in_future` برمی‌گردد
- زمانی که از پنجره گذشته عقب‌تر باشد به لبه آن پنجره گیره می‌شود و هشدار `timestamp_too_old` برمی‌گردد. پنجره همان نگهداشت رویداد خود حساب است: حسابی که رویدادها را برای همیشه نگه می‌دارد ۳۶۵۰ روز می‌گیرد، حسابی که ۹۰ روز تنظیم کرده ۹۰ روز می‌گیرد، و حسابی که روی کف ۳۰ روزه نشسته باز هم همان پیش‌فرض کامل ۳۰ روزه را دارد

### هنگام بستن صفحه {#unload}

SDK به `visibilitychange` و `pagehide` گوش می‌دهد و در هر دو یک بسته را با `navigator.sendBeacon` می‌فرستد، به شکل یک `Blob` از نوع `application/json` به این آدرس:

```text
{apiHost}/v1/batch?write_key={writeKey}
```

کلید نوشتن اینجا در query string می‌رود چون `sendBeacon` نمی‌تواند هدر ست کند و کالکتور درست برای همین حالت آن را آنجا هم می‌پذیرد.

پیام‌های beacon شده **از صف پاک نمی‌شوند**. `sendBeacon` فقط گزارش می‌دهد که درخواست به مرورگر تحویل داده شد، هرگز نمی‌گوید رسید. ماندنشان یعنی بارگذاری بعدی ممکن است دوباره بفرستد، که بی‌خطر است، چون هر پیام `message_id` ثابت دارد.

### کلیدهای ذخیره‌سازی {#storage}

| کلید | محتوا |
|---|---|
| `segmentic_anonymous_id` | شناسه ناشناس این مرورگر |
| `segmentic_user_id` | آخرین شناسه‌ای که به `identify()` داده شده |
| `segmentic_queue` | صف خروجی |
| `segmentic_session` | شناسه نشست، زمان شروع و زمان آخرین فعالیت |
| `segmentic_opt_out` | مقدار `1` یعنی انصراف داده است |
| `segmentic_campaign` | کمپینی که این بازدید به آن نسبت داده شده، با زمانش |
| `segmentic_traits` | ویژگی‌های اسکالر آخرین `identify()`، برای تارگتینگ درون‌سایت |
| `segmentic_seen` | این مرورگر پیش‌تر اینجا بوده است. با `reset()` پاک نمی‌شود |
| `sg_onsite` | چند بار هر کمپین درون‌سایت دیده، بسته یا تبدیل شده |

به‌علاوه یک ورودی Cache API با نام `segmentic-config` و کلید `/__segmentic_push_config` که فقط برای اعلان مرورگر است.

هیچ کوکی‌ای نوشته نمی‌شود.

## رضایت، انصراف و Do Not Track {#consent}

`optOut()` جمع‌آوری را متوقف می‌کند **و صف را پاک می‌کند**. این عمدی است: رعایت انصراف فقط برای رویدادهای آینده، در حالی که چیزی که پیش‌تر گرفته شده بی‌سروصدا تحویل داده شود، انصراف نیست.

بعد از `optOut()`:

- `enqueue` بی‌درنگ برمی‌گردد، یعنی `track`، `identify`، `page`، `screen` و `alias` هیچ‌چیز نمی‌سازند
- `flush()` بدون هیچ درخواستی resolve می‌شود
- beacon هنگام بستن صفحه کاری نمی‌کند
- کلیک کمپین ثبت نمی‌شود، پس `segmentic_campaign` هرگز نوشته نمی‌شود
- `subscribeToPush()` با `{ state: "failed", reason: "..." }` برمی‌گردد
- هیچ پیام درون‌سایتی کشیده نمی‌شود

پرچم انصراف در `localStorage` می‌ماند، پس از بارگذاری دوباره جان سالم به در می‌برد. `optIn()` پرچم و کلید را پاک می‌کند و تایمر ارسال را دوباره راه می‌اندازد.

> [!warn]
> یک نکته صادقانه: `optOut()` فقط تایمر ارسال را متوقف می‌کند. تایمر شصت‌ثانیه‌ای درون‌سایت متوقف نمی‌شود، پس اگر SDK قبل از انصراف شروع به نظرسنجی کرده باشد، همچنان هر شصت ثانیه یک `GET /v1/onsite` می‌زند. آن درخواست هیچ هویتی حمل نمی‌کند و هیچ چیزی کشیده یا گزارش نمی‌شود، ولی درخواست ادامه دارد. فقط `close()` روی نمونه، آن را می‌بندد.

**Do Not Track** با `respectDoNotTrack: true` (پیش‌فرض) خوانده می‌شود: به ترتیب `navigator.doNotTrack`، سپس `globalThis.doNotTrack`، سپس `navigator.msDoNotTrack`؛ مقدار `"1"` یا `"yes"` یعنی انصراف. این فقط **یک بار، در سازنده** خوانده می‌شود. عوض‌کردن تنظیم مرورگر وسط نشست تا `init()` بعدی هیچ اثری ندارد. به همین دلیل `optIn()` در همان نشست حتی زیر Do Not Track هم جمع‌آوری را دوباره روشن می‌کند.

## اعلان مرورگر {#push}

دو پیش‌نیاز دارد و هیچ‌کدام اختیاری نیستند.

**یک: `identify()` باید اجرا شده باشد.** اشتراک به یک آدم ذخیره می‌شود و اشتراکی که برای بازدیدکننده ناشناس ذخیره شود هرگز با هیچ سگمنتی هدف‌گیری نمی‌شود. بدون آن `{ state: "failed", reason: "اول باید identify صدا زده شود تا اشتراک به کاربر وصل شود" }` برمی‌گردد.

**دو: کلید عمومی VAPID.** بدون آن `{ state: "failed", reason: "کلید VAPID تنظیم نشده است" }` برمی‌گردد.

> [!warn]
> کلید عمومی VAPID امروز در هیچ صفحه‌ای از پنل نشان داده نمی‌شود و هیچ اندپوینتی آن را برنمی‌گرداند. یک کلید در سطح کل نصب است که با `adminctl vapid` ساخته و به شکل `VAPID_PUBLIC_KEY` در محیط کالکتور و ورکر گذاشته می‌شود. برای گرفتنش با تماس خودتان در سگمنتیک هماهنگ کنید. عوض‌شدن این کلید، اشتراک هر مرورگری که پیش‌تر ثبت شده را باطل می‌کند.

> [!warn]
> **اشتراک گرفتن با رسیدن یکی نیست، و امروز این شکاف واقعی است.** مسیر تحویل پیش از آنکه به فرستنده اعلان مرورگر برسد، از دفتر دستگاه‌ها می‌پرسد این `user_id` چند نصب دارد، و کانال `webpush` را دقیقا مثل پوش موبایل حساب می‌کند: نشانی یک نصب، نه نشانی یک آدم. اگر آن کاربر هیچ ردیفی در دفتر دستگاه‌ها نداشته باشد، پیام پیش از هر تلاشی با دلیل `not_reachable` کنار گذاشته می‌شود. این SDK فقط `POST /v1/webpush/subscribe` را صدا می‌زند و هیچ‌وقت `POST /v1/devices` را، پس بازدیدکننده‌ای که فقط مرورگر دارد و اپی نصب نکرده، اشتراکش ذخیره می‌شود و هیچ کمپینی به او نمی‌رسد. در گزارش هم به شکل «شکست» دیده نمی‌شود، به شکل «قابل دسترسی نبود». پیش از اینکه اعلان مرورگر را روی یک سایت بدون اپ راه بیندازید، این را با تماس خودتان در سگمنتیک بررسی کنید.

### سرویس ورکر {#service-worker}

فایل `segmentic-sw.js` را باید **از ریشه مبدأ خودتان** سرو کنید. دامنه یک ورکر نمی‌تواند از مسیری که از آن سرو شده گسترده‌تر باشد، پس ورکری که از `/static/` بیاید فقط برای صفحه‌های زیر `/static/` پوش می‌گیرد. مبدأ ما هم به کار نمی‌آید: سرویس ورکر باید هم‌مبدأ با صفحه باشد.

نسخه جاری فایل از پنل قابل دانلود است:

```bash
curl -o segmentic-sw.js https://app.segmentic.net/segmentic-sw.js
```

آن را کنار `index.html` خودتان بگذارید تا روی `https://your-site.example/segmentic-sw.js` سرو شود. اگر جای دیگری گذاشتید، `serviceWorkerPath` و `scope` را متناسبش بدهید.

کاری که ورکر می‌کند: در `install` بلافاصله `skipWaiting` و در `activate` بلافاصله `clients.claim` می‌زند، پس نسخه تازه بدون بسته‌شدن همه تب‌ها تحویل کار را می‌گیرد. اعلان‌ها با `dir: "rtl"` و `lang: "fa"` کشیده می‌شوند و `badge` به `icon` برمی‌گردد. پوشی که JSON ما نباشد باز هم یک اعلان با عنوان `پیام جدید` نشان می‌دهد، چون هیچ مرورگری پوش خاموش را اجازه نمی‌دهد و کروم به‌جایش اعلان «این سایت در پس‌زمینه به‌روز شد» خودش را می‌گذارد که بدتر است.

کلیک روی اعلان، اگر تبی با همان آدرس باز باشد همان را فوکوس می‌کند، وگرنه پنجره تازه باز می‌کند. شناسه‌ها (`sg_mid` و توکن امضاشده) روی خود آدرس سوارند، پس کلیک را سایت خودتان موقع بارگذاری گزارش می‌کند و هیچ سرویس ریدایرکتی از ما وسط نیست. همین است که وقتی آنالیتیکس ما کار نکند، لینک همچنان کار می‌کند.

ورکر به `pushsubscriptionchange` هم گوش می‌دهد و اشتراک تازه را دوباره پست می‌کند. برای این کار به `apiHost`، کلید نوشتن، کلید عمومی و شناسه کاربر نیاز دارد و آن‌ها را موقع اشتراک در Cache API ذخیره می‌کنیم، چون این رویداد ممکن است بدون هیچ تب بازی اجرا شود و ورکر به متغیرهای صفحه دسترسی ندارد. بدون این، سرویس پوش می‌تواند اشتراک را خودش بچرخاند و آن آدم بی‌صدا از هر کمپینی بیفتد بیرون؛ تنها نشانه‌اش نرخ تحویلی است که در طول ماه‌ها آرام پایین می‌رود.

### متدهای پوش {#push-api}

```ts
subscribeToPush(options: {
  publicKey: string;             // اجباری
  serviceWorkerPath?: string;    // پیش‌فرض "/segmentic-sw.js"
  scope?: string;                // پیش‌فرض "/"
}): Promise<{ state: PushState; reason?: string }>

unsubscribeFromPush(): Promise<boolean>
pushPermission(): NotificationPermission | "unsupported"
```

`PushState` یکی از `subscribed`، `denied`، `dismissed`، `unsupported` یا `failed` است. `reason` یک رشته فارسی است که می‌شود به بازدیدکننده نشان داد یا لاگ کرد.

**`subscribeToPush` را از داخل یک کلیک صدا بزنید.** مرورگر فقط یک بار به یک سایت اجازه پرسیدن می‌دهد، و کروم اگر به‌اندازه کافی آدم پنجره را ببندند، پرسیدن آن سایت را برای همیشه می‌بندد. پس درخواست بی‌مقدمه هنگام بارگذاری صفحه، مطمئن‌ترین راه از دست دادن دائمی این کانال است. SDK این را اجبار نمی‌کند و جلوی شما را نمی‌گیرد؛ فقط عواقبش برگشت‌ناپذیر است.

```html
<button id="notify">اعلان تخفیف‌ها را روشن کن</button>

<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net"
  });
  Segmentic.identify("u_123", { city: "تهران" });

  document.getElementById("notify").addEventListener("click", async () => {
    const result = await Segmentic.subscribeToPush({
      publicKey: "BEl62iUYgUivxIkv69yViEuiBIa40HI0DLLuxazjqAKeFXlyeeVpMS0"
    });
    if (result.state !== "subscribed") {
      console.log(result.state, result.reason);
    }
  });
</script>
```

ترتیب بررسی‌ها، و پاسخ هرکدام:

| حالت | `state` | `reason` |
|---|---|---|
| کاربر انصراف داده | `failed` | `کاربر از ردیابی انصراف داده است` |
| `identify()` صدا زده نشده | `failed` | `اول باید identify صدا زده شود تا اشتراک به کاربر وصل شود` |
| مرورگر یکی از سه API را ندارد | `unsupported` | `این مرورگر از اعلان وب پشتیبانی نمی‌کند` |
| `publicKey` خالی | `failed` | `کلید VAPID تنظیم نشده است` |
| اجازه پیش‌تر رد شده | `denied` | `کاربر قبلاً اجازهٔ اعلان را رد کرده است` |
| پنجره اجازه با رد بسته شد | `denied` | `اجازهٔ اعلان داده نشد` |
| پنجره اجازه بدون جواب بسته شد | `dismissed` | `پنجرهٔ اجازه بسته شد` |
| ثبت روی سرور شکست خورد | `failed` | `ثبت اشتراک روی سرور انجام نشد` |
| موفق | `subscribed` | ندارد |

سه نکته در این جدول:

اگر `Notification.permission` از قبل `denied` باشد، SDK **دیگر `requestPermission` را صدا نمی‌زند**. پرسیدن دوباره از کسی که پیش‌تر نه گفته، هم بی‌فایده است و هم در کروم یک قدم به سمت بسته‌شدن دائمی پنجره‌های آن سایت.

`dismissed` از `denied` جداست چون معنی‌اش فرق دارد. کسی که پنجره را بست، رد نکرده است؛ سایت می‌تواند بعد دوباره بپرسد، و یک `denied` سفت این را به اشتباه منتفی می‌کرد.

اگر اشتراکی از قبل روی این ثبت وجود داشته باشد، همان **دوباره استفاده می‌شود** نه اینکه یکی تازه ساخته شود. لغو و ثبت دوباره یک endpoint تازه می‌سازد و ردیف قدیمی در جدول به اشتراکی اشاره می‌کند که مرورگر فراموشش کرده؛ بعد از آن هر کمپین به ازای هر endpoint کهنه یک شکست گزارش می‌دهد.

سه بررسی دیگر که SDK می‌کند و در جدول نیست: `pushSupported()` هر سه تای `serviceWorker` در `navigator`، `PushManager` در `window` و `Notification` در `window` را می‌خواهد؛ بعد از ثبت ورکر، `navigator.serviceWorker.ready` انتظار کشیده می‌شود، چون صدا زدن `pushManager` روی ثبتی که هنوز در حال نصب است روی سافاری خطا می‌دهد؛ و اشتراک همیشه با `userVisibleOnly: true` گرفته می‌شود، چون هر مرورگری که Push API را پیاده کرده این را اجباری می‌داند.

بعد از موفقیت، این درخواست مستقیم فرستاده می‌شود، نه از راه صف:

```bash
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
      "p256dh": "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM",
      "auth": "tBHItJI5svbpez7KI4CCXg"
    }
  }'
```

```json
{ "status": "ok" }
```

از راه صف نمی‌رود چون هیچ بافری نمی‌تواند این را دوباره پخش کند: اجازه گرفته شده و مرورگر دوباره نمی‌پرسد، پس اشتراکی که به ما نرسد یعنی آدمی که راضی شده اعلان بگیرد و هرگز نمی‌شود به او رسید. به همین دلیل پاسخ غیر `2xx` به شکل `{ state: "failed" }` به فراخوان برگردانده می‌شود تا صفحه میزبان بتواند دوباره تلاش کند.

`unsubscribeFromPush()` اشتراک مرورگر را می‌خواند، `unsubscribe()` می‌زند و بعد `POST {apiHost}/v1/webpush/unsubscribe` با بدنه `{ "endpoint": "..." }` می‌فرستد. اگر اشتراکی نبود، `false` برمی‌گرداند بدون هیچ درخواستی. سرور هیچ شناسه کاربری نمی‌خواهد و هیچی چک نمی‌کند: خود endpoint راز آن اشتراک است و داشتنش از قبل برای فرستادن به آن مرورگر کافی است، پس سخت‌گیری برای **قطع** کردن، محافظت از جهت اشتباه است.

`pushPermission()` مقدار `Notification.permission` را بدون پرسیدن برمی‌گرداند، یا `"unsupported"` وقتی هر سه API موجود نباشند.

## پیام‌های درون‌سایت {#onsite}

با `onsite: true` (پیش‌فرض) SDK فهرست کمپین‌های زنده را می‌گیرد و **در مرورگر** تصمیم می‌گیرد کدام را نشان بدهد.

```text
GET {apiHost}/v1/onsite?write_key={writeKey}
```

یک بار موقع `init()` و بعد هر شصت ثانیه. شصت ثانیه درست برابر `Cache-Control` پاسخ است؛ گرفتن سریع‌تر کش را از دست می‌دهد و یک درخواست روی بارگذاری صفحه مشتری می‌گذارد برای جوابی که ممکن نیست عوض شده باشد. پاسخ هیچ هویتی حمل نمی‌کند، پس برای همه بازدیدکننده‌ها یکی است و قابل کش‌شدن.

شکست این درخواست هیچ صدایی ندارد. این کد داخل بارگذاری صفحه یک نفر دیگر اجرا می‌شود؛ خرابی ما باید به «امروز بنری نیست» تنزل کند، نه به یک خطای قرمز در کنسول سایت آن‌ها. سمت سرور هم همین‌طور: خطای پایگاه‌داده به‌جای `5xx` یک فهرست خالی برمی‌گرداند.

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

قواعدی که در مرورگر ارزیابی می‌شوند: `url_contains`، `url_not_contains`، `devices`، `new_visitors_only`، `returning_only`، `logged_in` و `traits`.

تطبیق آدرس **زیررشته‌ای است، نه regex**. الگویی که یک بازاریاب می‌نویسد الگویی است که می‌تواند فاجعه‌بار کند باشد، و این روی هر صفحه سایت یکی دیگر می‌دود.

نوع دستگاه از **عرض viewport** می‌آید نه از User-Agent: کمتر از ۷۶۸ یعنی `mobile`، کمتر از ۱۰۲۴ یعنی `tablet`، بقیه `desktop`. تشخیص از User-Agent روی هر دستگاهی که درباره خودش دروغ می‌گوید غلط است، که امروز بیشترشان‌اند، و کمپینی که «موبایل» را هدف می‌گیرد در عمل یعنی «صفحه باریک».

`traits` برابری ساده با ویژگی‌هایی است که آخرین `identify()` در همین مرورگر گذاشته، نه با انبار داده. مرورگر فقط چیزی را دارد که به آن داده شده و وانمود به خلافش، هر قاعده ویژگی را بی‌صدا غلط می‌کرد.

سقف تکرار در همین ترتیب اعمال می‌شود:

1. کمپین خارج از بازه `starts_at` و `ends_at` باشد: نه. `ends_at` بازه را نمی‌گیرد، یعنی لحظه پایان دیگر زنده نیست
2. تا حالا دیده نشده: بله
3. `converted` ثبت شده: نه، برای همیشه. کسی که کار را انجام داده نباید دوباره پرسیده شود، و این از هر قاعده دیگری، حتی از زنده‌بودن کمپین، بالاتر است
4. `dismissed` ثبت شده **و** کمپین `dismissible` است: نه. بنر غیرقابل‌بستن باز هم نشان داده می‌شود
5. `max_impressions` بزرگ‌تر از صفر است و شمار دیده‌شدن به آن رسیده: نه. صفر یعنی بی‌سقف
6. `cooldown_hours` بزرگ‌تر از صفر است و از آخرین دیدن کمتر از آن گذشته: نه

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

یک کلیک برای حساب سقف، **تبدیل** شمرده می‌شود: کسی که لینک را دنبال کرده کار را انجام داده و نشان‌دادن دوباره یعنی خواستن دو بار انجامش.

### چه چیزی کشیده می‌شود {#onsite-render}

هر چهار نوع کشیده می‌شوند: `banner` (نوار بالا یا پایین)، `modal` (وسط، با پرده پشتش)، `slidein` (گوشه) و `survey` (گوشه، با NPS صفر تا ده یا فهرست گزینه).

**در هر لحظه فقط یک کمپین.** دو مودال هم‌زمان طراحی هیچ‌کس نیست و دومی دکمه بستن اولی را می‌پوشاند. اولین کمپین واجد شرایط، به ترتیبی که سرور فرستاده، انتخاب می‌شود.

سه قاعده کل کد رندر را شکل می‌دهند و هر سه درباره مهمان‌بودن روی صفحه یک نفر دیگرند:

- **هرگز خطا پرتاب نکن.** یک ویجت آنالیتیکس نباید چیزی باشد که پرداخت را می‌شکند. هر نقطه ورود در try پیچیده شده است
- **هرگز ارث نبر.** ریست CSS، فونت و قاعده‌های `* { }` صفحه میزبان وگرنه ویجت را جوری بازشکل می‌دادند که هیچ‌کس پیش‌نمایشش را ندیده. هر ویژگی مهم مستقیم روی خود عنصر ست می‌شود
- **هرگز مارک‌آپ تزریق نکن.** محتوا با `textContent` نوشته می‌شود، هرگز با `innerHTML`. تیتر از یک فیلد پنل می‌آید و مشتری‌ای که مارک‌آپ داخلش پیست کند نباید اسکریپتی روی سایت خودش اجرا شود

آدرس دکمه فقط وقتی `href` می‌شود که با `http://` یا `https://` شروع شود یا با `/` آغاز شود. آدرس `javascript:` هیچ `href` نمی‌گیرد. متن دکمه سر جایش می‌ماند و کلیک همچنان گزارش می‌شود.

جزئیات چیدمان: ظرف یک `div` با `position: fixed` و شناسه `segmentic-onsite` است، `z-index` برابر `2147483000` (زیر بیشینه، تا اورلی خود مشتری بتواند برنده شود)، `pointer-events: none` روی ظرف با هر ویجت که کلیک را برای خودش برمی‌گرداند (وگرنه یک div نامرئی تمام‌صفحه هر کلیک روی سایت مشتری را می‌بلعید)، و `direction: rtl` روی ظرف، چون ویجتی که فارسی نوشته شده باید راست‌به‌چپ خوانده شود حتی روی صفحه‌ای که نیست. دکمه بستن سمت **چپ** است و `aria-label` آن `بستن`. رنگ‌های پیش‌فرض: پس‌زمینه `#1f2937`، متن `#ffffff`، رنگ تاکید `#2563eb`.

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

نظرسنجی: سوال، بعد یا ردیف NPS از صفر تا ده که داخل کارت راست‌به‌چپ با `direction: ltr` کشیده می‌شود (صفر چپ تا ده راست، همان‌طور که این مقیاس همه‌جا کشیده می‌شود) یا فهرست گزینه‌ها. اگر `follow_up` ست باشد بعد از جواب یک textarea نشان داده می‌شود و متنش به‌عنوان پاسخ دوم می‌رود. آن پاسخ دوم **کل** جواب را با خودش می‌برد، یعنی نمره یا گزینه را هم کنار متن تازه، چون ذخیره پشتش روی (کمپین، شخص) idempotent است و ردیف را جایگزین می‌کند نه اینکه به آن اضافه کند. تشکر پیش‌فرض `ممنون از وقتی که گذاشتید.` است و **دو ثانیه** سر جایش می‌ماند قبل از بسته‌شدن: ویجتی که همان لحظه جواب‌دادن ناپدید شود مثل یک گلیچ صفحه خوانده می‌شود نه مثل یک تایید.

برداشت (impression) **قبل از فرستادن گزارش**، محلی ثبت می‌شود، تا سقف حتی وقتی درخواست هرگز نرسد سر جایش باشد.

گزارش‌ها مستقیم می‌روند نه از راه صف، با `keepalive: true`، و خطایشان بلعیده می‌شود. یک برداشت که ده ثانیه دیر برسد اشکالی ندارد، ولی نباید پشت بسته‌ای بماند که منتظر نوزده پیام دیگر است روی صفحه‌ای که بازدیدکننده دارد ترکش می‌کند.

```bash
curl -X POST https://in.segmentic.net/v1/onsite/event \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 7,
    "action": "impression",
    "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
    "user_id": "u_123"
  }'
```

`action` یکی از `impression`، `click`، `dismiss` یا `convert` است. `user_id` وقتی بازدیدکننده ناشناس است حذف می‌شود.

پاسخ نظرسنجی جدا می‌رود. سرور فقط دو فیلد جواب می‌خواند و نه چیز دیگری: `score` که روی نظرسنجی NPS عددی از صفر تا ده است، و `answers` که نگاشتی از رشته به رشته است و جواب چندگزینه‌ای و متن آزاد جایشان همان‌جاست. **هر کلید دیگری موقع decode شدن بدنه دور ریخته می‌شود و پاسخ باز هم `200` است**، پس اگر نظرسنجی را خودتان می‌کشید فقط `answers` بفرستید. رندرکننده ما داخل آن نگاشت از کلیدهای `choice` و `text` استفاده می‌کند؛ پنل این دو تا را با برچسب خوانا نشان می‌دهد و هر کلید دلخواه شما را همان‌طور که فرستاده‌اید.

نفرستادن `score` روی نظرسنجی NPS با `400` رد می‌شود، نه اینکه صفر خوانده شود. این عمدی است و نیمه دوم یک باگ همین SDK بود: نبودن عدد یعنی نبودن عدد، نه بدترین عدد مقیاس.

هر دوی این‌ها تا همین اواخر غلط بودند و جواب‌هایی که در آن فاصله از دست رفته‌اند برگشتنی نیستند. [پیام‌های درون‌سایت](/docs/onsite#survey-defects) می‌گوید چه چیزی را باید بررسی کنید.

```bash
curl -X POST https://in.segmentic.net/v1/onsite/response \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 7,
    "anonymous_id": "6b7d2c11-8a45-4f0e-9d33-1c2e5b8a7f44",
    "user_id": "u_123",
    "score": 9,
    "answers": { "reason": "ارسال سریع بود" }
  }'
```

### رندر با کد خودتان {#onsite-own}

اگر فروشگاه شما دیزاین‌سیستم خودش را دارد، رندر ما را خاموش کنید و منطق واجدشرایط‌بودن و سقف تکرار را نگه دارید. ارزیاب تارگتینگ از قصد export شده، چون همان بخشی است که ممکن است مشتری بخواهد خودش اجرا کند.

```html
<script src="https://in.segmentic.net/sdk/segmentic.js"></script>
<script>
  const segmentic = Segmentic.init({
    writeKey: "wk_seg_...",
    apiHost: "https://in.segmentic.net",
    onsite: false
  });

  // فهرست کمپین‌ها با onsite: false گرفته نمی‌شود، پس خودتان بگیرید.
  fetch("https://in.segmentic.net/v1/onsite?write_key=wk_seg_...")
    .then((res) => res.json())
    .then((body) => {
      const seen = Segmentic.readSeen(localStorage);
      const visitor = {
        url: location.href,
        device: Segmentic.deviceOf(window.innerWidth),
        loggedIn: segmentic.getUserId() !== null,
        returning: localStorage.getItem("segmentic_seen") === "1",
        traits: JSON.parse(localStorage.getItem("segmentic_traits") || "{}"),
        now: Date.now()
      };
      const list = Segmentic.eligible(body.campaigns || [], visitor, seen);
      if (list[0]) {
        myOwnBanner(list[0]);
        Segmentic.writeSeen(
          localStorage,
          Segmentic.recordSeen(seen, list[0].id, visitor.now)
        );
      }
    });
</script>
```

> [!note]
> با `onsite: false` هیچ فهرستی گرفته نمی‌شود، پس `onsiteCampaigns()` آرایه خالی می‌دهد و `refreshOnsite()` بی‌درنگ برمی‌گردد. اگر `onsite` را روشن بگذارید ولی رندر خودتان را بخواهید، راهی برای خاموش‌کردن فقط رندر وجود ندارد.

## CORS و CSP {#cors}

هر نقطه‌ای روی کالکتور که کلید نوشتن می‌خواهد، این هدرها را قبل از هر کاری می‌نویسد، حتی قبل از احراز هویت، تا مرورگر کد وضعیت واقعی را ببیند نه یک خطای CORS. این شامل هرچه این SDK صدا می‌زند می‌شود: `/v1/batch`، `/v1/webpush/subscribe` و `/v1/webpush/unsubscribe`، و هر سه نقطه درون‌سایت.

```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400
```

`Access-Control-Allow-Credentials` از قصد ست نمی‌شود. مبدأ `*` فقط به همین دلیل بی‌خطر است.

`GET /v1/status` هیچ‌کدام از این هدرها را ندارد، پس مرورگر نمی‌تواند آن را از صفحه شما بخواند. آن نقطه برای یک مانیتور یا `curl` است، نه برای بررسی سلامتی که در فرانت‌اند بزنید. نقطه‌های ایمیلی زیر `/e/` هم این هدرها را ندارند.

فهرست متدها `GET` ندارد و لازم هم نیست: تنها درخواست `GET` که SDK می‌زند، گرفتن کمپین‌های درون‌سایت است که هیچ هدر سفارشی ندارد، پس درخواستی ساده است و هیچ preflight نمی‌شود.

برای Content-Security-Policy یک ورودی کافی است، چون باندل و رویدادها هر دو روی همان مبدأ‌اند:

```text
script-src https://in.segmentic.net;
connect-src https://in.segmentic.net;
```

اگر پیام درون‌سایت با `image_url` منتشر می‌کنید، `img-src` هم باید مبدأ همان تصویر را داشته باشد. ویجت‌ها استایلشان را inline روی خود عنصر می‌گذارند، نه با تگ `style`، پس `style-src` دست نمی‌خورد.

## حجم اندازه‌گیری‌شده {#size}

اندازه‌گیری روی فایل ساخته‌شده‌ای که همین حالا سرو می‌شود:

| فایل | خام | با gzip |
|---|---|---|
| باندل تگ اسکریپت (IIFE) | ۲۶۰۹۱ بایت | ۸۸۳۲ بایت، همان‌طور که سرو می‌شود |
| ESM | ۲۵۵۷۹ بایت | ۸۵۸۰ بایت با `gzip -9` محلی |
| CJS | ۲۶۳۸۰ بایت | ۸۸۶۹ بایت با `gzip -9` محلی |

عدد gzip یکی نیست و به فشرده‌ساز و سطحش بستگی دارد، پس نباید وانمود کنیم یکی است. ۸۸۳۲ همان چیزی است که `in.segmentic.net` روی سیم برمی‌گرداند و همان چیزی است که بازدیدکننده دانلود می‌کند؛ همان بایت‌ها با `gzip -9` روی لپ‌تاپ ۸۷۹۳ می‌شوند. ESM و CJS را ما میزبانی نمی‌کنیم، پس برایشان فقط عدد محلی هست.

یعنی حدود هشت و نیم کیلوبایت برای نسخه تگ اسکریپت. هیچ وابستگی زمان اجرایی ندارد.

`README` داخل مخزن تا همین اواخر «۴.۲ کیلوبایت gzip» می‌گفت. آن عدد کهنه بود و حدود نصف عدد واقعی؛ رشد از وقتی است که رندر درون‌سایت و اعلان مرورگر اضافه شدند. خود README حالا آن را در فهرست ادعاهای غلطش آورده و همین ۸۸۳۲ را می‌نویسد. اگر جای دیگری به عدد قدیمی برخوردید، به این صفحه اعتماد کنید.

## عیب‌یابی {#debug}

`debug: true` بدهید تا هر کار SDK با پیشوند `[segmentic]` در کنسول نوشته شود: صف‌شدن هر پیام، ارسال beacon، حذف‌ها با دلیلشان، انتساب کمپین، شکست ارسال با تخمین فاصله تلاش بعدی، و رسیدن به سقف تلاش.

```js
const segmentic = Segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
  debug: true
});

setInterval(() => console.table(segmentic.stats()), 5000);
```

سه چیزی که در `stats()` باید ببینید:

- `durableStorage: false` یعنی `localStorage` در دسترس نبوده و صف با بستن تب می‌رود. حالت خصوصی سافاری یا سهمیه پر
- `dropped` که بالا می‌رود در حالی که `failures` صفر است، یعنی سرور دارد `4xx` می‌دهد. با `debug` کد وضعیت را در کنسول ببینید
- `queued` که بالا می‌رود و `sent` که تکان نمی‌خورد، یعنی هیچ ارسالی موفق نبوده. اول CORS، بعد درست‌بودن `apiHost` را نگاه کنید

یک چیزی که در پنل **نمی‌بینید**: دیباگر رویداد زنده نصب این SDK را نشان نمی‌دهد. ضبط دیباگ فقط روی مسیر تک‌رویدادی صدا زده می‌شود و این SDK همیشه `/v1/batch` می‌فرستد، پس آن صفحه برای یک نصب SDK همیشه خالی است. برای اینکه ببینید رویدادها رسیده‌اند یا نه، صفحه «اتصال» پنل را باز بگذارید؛ آن از فعالیت اپ در بیست‌وچهار ساعت گذشته می‌پرسد و بسته‌ها را می‌بیند.

برای یک بررسی سریع بدون مرورگر:

```bash
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"anonymous_id":"test-1","event":"install_check"}'
```

```json
{ "status": "ok", "accepted": 1 }
```

## کارهایی که از قصد انجام نمی‌شود {#never}

- **هیچ کوکی‌ای نمی‌نویسد.** فقط `localStorage` و یک ورودی Cache API برای پوش
- **هیچ‌چیز را خودکار شکار نمی‌کند.** نه کلیک، نه ارسال فرم، نه خطای جاوااسکریپت، نه ضبط جلسه. فقط چیزی می‌رود که خودتان صدا زده‌اید
- **به تاریخچه مرورگر قلاب نمی‌زند.** در اپ تک‌صفحه‌ای، `page()` را خودتان صدا بزنید
- **`device` و `os` نمی‌فرستد.** کالکتور از هدر User-Agent درشان می‌آورد، چون کلاینت نمی‌تواند آن را جعل کند
- **fingerprint نمی‌گیرد.** هیچ canvas، هیچ فهرست فونت، هیچ شناسه‌ای که از سخت‌افزار مشتق شده باشد
- **توکن کلیک را بازرسی نمی‌کند.** `sg_t` فقط حمل و برگردانده می‌شود
- **در تارگتینگ regex اجرا نمی‌کند.** فقط زیررشته
- **`innerHTML` نمی‌نویسد.** هیچ‌جا
- **پیام‌های beacon شده را از صف پاک نمی‌کند.** یکتاسازی سمت سرور این را بی‌خطر می‌کند
- **در انصراف، صف را نگه نمی‌دارد.** پاکش می‌کند
- **خطا پرتاب نمی‌کند**، به‌جز `init()` بدون `writeKey` یا بدون `apiHost`
- **اشتراک اعلان مرورگر را در دفتر دستگاه‌ها ثبت نمی‌کند.** فقط `POST /v1/webpush/subscribe`. بخش [اعلان مرورگر](/docs/sdk-web#push) می‌گوید چرا این مهم است
- **کلاینت صندوق پیام (inbox) ندارد.** اندپوینت‌های `POST /v1/inbox` و `POST /v1/inbox/ack` روی کالکتور وجود دارند و تست دارند، ولی هیچ SDKی برایشان کد ندارد. اگر لازمشان دارید، خودتان صدایشان بزنید
- **روی هیچ رجیستری npm منتشر نشده است.** بخش [با باندلر](/docs/sdk-web#bundler) را ببینید

---

مرتبط: [شروع سریع](/docs/quickstart) برای اولین رویداد، [هویت](/docs/identity) برای اینکه تاریخچه ناشناس چطور به حساب وصل می‌شود، [رضایت](/docs/consent) برای سیاست انصراف در کل پلتفرم، [پیام‌های درون‌سایت](/docs/onsite) برای ساختن کمپین در پنل، [دستگاه‌ها و پوش](/docs/devices) برای بقیه کانال‌های اعلان، و [خطاها](/docs/errors) و [سقف‌ها](/docs/limits) برای رفتار کالکتور.

---

# SDK اندروید

> SDK رسمی اندروید برای ثبت رویداد، هویت، پوش و پیام درون‌برنامه‌ای.
> https://segmentic.net/docs/sdk-android

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

## چطور به بیلد شما می‌رسد {#how-it-ships}

این SDK به صورت سورس و از روی مخزن می‌آید، نه از یک مخزن بسته. یک بار اضافه‌اش می‌کنید و بعد همان مختصات همیشگی را می‌نویسید:

```kotlin
implementation("net.segmentic:segmentic-android:0.1.0")
```

دو مسیر شما را به اینجا می‌رساند و هر دو در [افزودن به اپ](/docs/sdk-android#install) با تمام دستورها نوشته شده‌اند: بیلد ترکیبی با `includeBuild`، یا انتشار محلی با `publishToMavenLocal`. اگر مخزن SDK کنار پروژه خودتان است اولی را بردارید، وگرنه دومی را.

هرچه پایین‌تر می‌آید درباره کدی است که اجرا شده، روی شبیه‌ساز اندروید ۱۵ و نه روی کاغذ: یک توکن واقعی فایربیس که با `POST /v1/devices` ثبت شد، پیامی که FCM v1 هم در حالت باز بودن اپ و هم در پس‌زمینه تحویل داد، و یک بنر درون‌برنامه‌ای که کشیده شد و بار دوم سقف تکرار خودش جلویش را گرفت.

## سه ماژول {#modules}

| ماژول | چیست | کجا تست می‌شود |
|---|---|---|
| `segmentic-core` | کاتلین خالص، بدون حتی یک import اندرویدی. صف، backoff، شکل سیم، قاعده‌های پیام درون‌برنامه‌ای، حالت سه‌گانه مجوزها | JVM ساده، در چند میلی‌ثانیه، بدون شبیه‌ساز |
| `segmentic-android` | لایه نازک اندروید: فایل کجا بنشیند، دستگاه چه می‌گوید، کار روی کدام ترد برود، و رندر پیام درون‌برنامه‌ای | دستگاه یا شبیه‌ساز |
| `sample` | اپی که SDK را دقیقا مثل یک مشتری صدا می‌زند. منتشر نمی‌شود | شبیه‌ساز |

دلیل این تقسیم در خود `settings.gradle.kts` نوشته شده: هر قاعده‌ای که می‌شود اشتباه نوشت در ماژول اول است و روی یک JVM ساده تست می‌شود. قاعده‌ای که فقط با شبیه‌ساز قابل بررسی باشد، قاعده‌ای است که کمتر بررسی می‌شود.

شما فقط `segmentic-android` را اعلام می‌کنید. `segmentic-core` با آن می‌آید، چون POM ماژول اندروید وابستگی‌اش را اعلام کرده است.

## افزودن به اپ {#install}

### مسیر یک: بیلد ترکیبی {#install-composite}

اگر مخزن SDK کنار مخزن خودتان است، این ساده‌ترین راه است و هیچ انتشاری نمی‌خواهد. Gradle خودش مختصات را با پروژه محلی جایگزین می‌کند.

```kotlin title="settings.gradle.kts (اپ شما)"
includeBuild("../segmentic/sdk/android")
```

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("net.segmentic:segmentic-android:0.1.0")
}
```

### مسیر دو: انتشار محلی {#install-mavenlocal}

یک بار در مخزن SDK اجرا کنید:

```bash
cd segmentic/sdk/android
./gradlew :segmentic-core:publishToMavenLocal :segmentic-android:publishToMavenLocal
```

چهار فایل در `~/.m2/repository/net/segmentic/` می‌نشیند. بعد در اپ خودتان `mavenLocal()` را اضافه کنید:

```kotlin title="settings.gradle.kts (اپ شما)"
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        mavenLocal()
    }
}
```

```kotlin title="app/build.gradle.kts"
dependencies {
    implementation("net.segmentic:segmentic-android:0.1.0")
}
```

هر دو ماژول یک `sources.jar` هم منتشر می‌کنند. این عمدی است: کامنت‌های داخل `segmentic-core` توضیح می‌دهند چرا صف از جلو افت می‌کند و چرا یک `4xx` دور ریخته می‌شود، و کسی که نصفه‌شب دنبال مشکل اپ خودش می‌گردد باید بتواند همان را در IDE بخواند نه اینکه حدس بزند.

وقتی روزی مخزن راه بیفتد، دستور انتشارش این است و هیچ چیز دیگری در کد عوض نمی‌شود:

```bash
./gradlew publish \
  -PsegmenticRepoUrl=https://example.invalid/maven \
  -PsegmenticRepoUser=... \
  -PsegmenticRepoPassword=...
```

## بدون وابستگی، و چه چیزی می‌خرد {#no-dependencies}

`segmentic-core` هیچ وابستگی تولیدی ندارد. صفر. نه کتابخانه JSON، نه کلاینت HTTP. `segmentic-android` دقیقا یکی دارد و آن هم `segmentic-core` است.

یک نکته که README خود SDK نمی‌گوید و اینجا باید بگوییم: هر دو POM منتشرشده `org.jetbrains.kotlin:kotlin-stdlib:2.0.21` را در scope برابر `compile` اعلام می‌کنند. پس «بدون وابستگی شخص‌ثالث» درست است، ولی «بدون هیچ وابستگی» تحت‌اللفظی درست نیست. کتابخانه استاندارد کاتلین را هر اپ کاتلینی از قبل دارد.

چیزی که این می‌خرد یک چیز مشخص است: SDK نمی‌تواند نسخه کتابخانه‌ای را به‌جای شما انتخاب کند. کتابخانه‌ای که یک پارسر JSON یا یک کلاینت HTTP با خودش می‌آورد، با نسخه‌ای که اپ شما از قبل دارد تصادم می‌کند، و بعد شما هستید که باید درخت وابستگی ما را دیباگ کنید.

هیچ منبعی هم داخل کتابخانه نیست: `buildConfig = false` و `androidResources = false`. به همین دلیل دکمه بستن پیام درون‌برنامه‌ای کاراکتر `×` است نه یک آیکون؛ یک آیکون اولین منبعی می‌شد که وارد APK شما می‌شد.

`consumer-rules.pro` عمدا خالی از قاعده است. هیچ چیزی با reflection صدا زده نمی‌شود و هیچ کلاسی با اسم بارگذاری نمی‌شود، پس R8 آزاد است همه‌اش را کوچک و نام‌عوض کند. فایل وجود دارد تا این یک تصمیم ثبت‌شده بماند، نه چیزی که بعدا کسی باید دوباره از اول نتیجه بگیرد.

اندازه‌های اندازه‌گیری‌شده، از خروجی واقعی `publishToMavenLocal`:

| فایل | بایت |
|---|---|
| `segmentic-android-0.1.0.aar` | `31730` |
| `segmentic-android-0.1.0-sources.jar` | `6705` |
| `segmentic-core-0.1.0.jar` | `54948` |
| `segmentic-core-0.1.0-sources.jar` | `19996` |

داخل AAR پنج ورودی است: `AndroidManifest.xml`، `classes.jar`، `R.txt` خالی، `proguard.txt` و یک فایل متادیتا. هیچ منبعی، دقیقا همان چیزی که فایل بیلد قول داده.

عددی که **نداریم**: هزینه واقعی SDK داخل اپ شما، یعنی تعداد متد یا اضافه‌شدن حجم dex بعد از R8. هیچ اندازه‌گیری‌ای از این در مخزن نیست. اندازه AAR و jar را داریم و همان را نوشتیم.

کف پلتفرم: `minSdk = 24` (اندروید ۷)، `compileSdk = 35`، جاوا ۱۷ برای source و target. پایین‌تر رفتن یعنی وارد کردن قاعده‌های desugaring به بیلد شما، برای سهمی از دستگاه‌ها که حالا زیر یک درصد است.

## چه چیزی به منیفست شما اضافه می‌شود {#manifest}

دو چیز، و همین.

```html title="manifest کتابخانه، همان که با AAR merge می‌شود"
<uses-permission android:name="android.permission.INTERNET" />

<queries>
    <package android:name="com.google.android.gms" />
</queries>
```

`INTERNET` یک مجوز عادی است و از کاربر چیزی نمی‌پرسد.

`ACCESS_NETWORK_STATE` عمدا اعلام **نشده**. با آن می‌شد گزارش داد که اتصال دیتا است یا وای‌فای، که ستون قشنگی است و ارزش این را ندارد که بی‌سروصدا یک مجوز به منیفست کس دیگری اضافه کنیم. کد فقط وقتی آن را می‌خواند که اپ خودتان از قبل مجوزش را داشته باشد، و در این حالت `context.network` به رویدادها اضافه می‌شود. اگر نداشته باشید، آن کلید اصلا فرستاده نمی‌شود.

بلوک `queries` لازم است چون اندروید ۱۱ به بعد بسته‌های دیگر را پنهان می‌کند. بدون آن، جست‌وجوی Play Services روی گوشی‌ای که واقعا Play Services دارد `NameNotFoundException` می‌دهد، ما `has_gms=false` گزارش می‌کنیم، و هر پوش آن دستگاه بی‌دلیل از مسیر FCM کنار گذاشته می‌شود.

## راه‌اندازی {#init}

یک بار، در `Application.onCreate`:

```kotlin title="MyApp.kt"
package com.example.shop

import android.app.Application
import net.segmentic.sdk.SegmenticOptions
import net.segmentic.sdk.android.Segmentic

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        Segmentic.init(
            this,
            SegmenticOptions(
                writeKey = "wk_seg_...",
                apiHost = "https://in.segmentic.net",
            ),
        )
    }
}
```

و در منیفست خودتان:

```html title="AndroidManifest.xml"
<application android:name=".MyApp">
</application>
```

سه نکته که وقت می‌گیرند اگر ندانید:

**حتما `Application` بدهید، نه `Activity`.** کد `context.applicationContext` را می‌گیرد، ولی برای دنبال کردن Activity جلویی به خود `Application` نیاز دارد. اگر آنچه دادید `Application` نباشد، یک خط هشدار در logcat می‌آید و پیام درون‌برنامه‌ای دیگر قابل کشیدن نیست. بقیه چیزها کار می‌کند.

**صدا زدن دوباره `init` نادیده گرفته می‌شود.** متد `@Synchronized` است و بار دوم فقط «init called twice, ignoring the second call» را لاگ می‌کند. این با [SDK وب](/docs/sdk-web) فرق دارد، که در آن `init` دوم کلاینت قبلی را می‌بندد و جایش را می‌گیرد.

**`init` یک خواندن کوچک روی همان تردی که صدایش زده انجام می‌دهد**، تا صفی که از اجرای قبلی مانده بارگذاری شود. برای همین جایش `Application.onCreate` است: چند میلی‌ثانیه آنجا عادی است، و جایگزینش یک getter مسابقه‌ای بود که بدتر است.

## تنظیمات {#options}

`SegmenticOptions` یک `data class` در `segmentic-core` است. همه پیش‌فرض‌ها عین SDK وب‌اند، عمدا: مشتری‌ای که هر دو را دارد نباید در یک داشبورد دو رفتار batching متفاوت ببیند.

| گزینه | نوع | پیش‌فرض | معنی |
|---|---|---|---|
| `writeKey` | `String` | ندارد، اجباری | کلید نوشتن از پنل. عمومی است و می‌تواند داخل APK باشد |
| `apiHost` | `String` | ندارد، اجباری | آدرس Collector، مثلا `https://in.segmentic.net` |
| `batchSize` | `Int` | `20` | ارسال به‌محض اینکه این تعداد پیام بافر شد |
| `flushIntervalMs` | `Long` | `10_000` | حداکثر فاصله بین دو ارسال |
| `maxQueueSize` | `Int` | `500` | چند پیام حق دارند روی دیسک منتظر بمانند |
| `maxRetries` | `Int` | `10` | چند شکست پشت‌سرهم تا آهنگ تلاش مجدد از رشد بایستد |
| `autoContext` | `Boolean` | `true` | افزودن اپ و سیستم و صفحه و زبان و منطقه زمانی به هر پیام |
| `sessionTimeoutMs` | `Long` | `30 * 60_000` | فاصله بی‌کاری که رویداد بعدی را وارد نشست تازه می‌کند |
| `debug` | `Boolean` | `false` | لاگ در logcat با تگ `segmentic` |

**تنها جایی که این SDK استثنا پرتاب می‌کند همین‌جاست.** سازنده `SegmenticOptions` روی `writeKey` یا `apiHost` خالی یک `IllegalArgumentException` می‌دهد. این عمدی است و در زمان ساخت آبجکت اتفاق می‌افتد، نه بعدا وسط یک `track()`. هیچ متد دیگری در هیچ شرایطی throw نمی‌کند.

مقادیری که قابل احترام گذاشتن نیستند، قبل از هر کاری به بازه برگردانده می‌شوند:

| فیلد | به این محدود می‌شود |
|---|---|
| `apiHost` | `/` انتهایی حذف می‌شود |
| `batchSize` | بین `1` و `1000` |
| `flushIntervalMs` | دست‌کم `1000` |
| `maxQueueSize` | دست‌کم به اندازه `batchSize` |
| `maxRetries` | بین `1` و `100` |
| `sessionTimeoutMs` | دست‌کم `1000` |

کف `maxQueueSize` تزئینی نیست. دست‌کم یک بسته کامل باید جا شود، وگرنه صفی که پر شده هرگز نمی‌تواند یک ارسال را سرهم کند و بافر فقط با افت خالی می‌شود.

سه گزینه‌ای که SDK وب دارد و اینجا **نیست**: `autoPageView` (اپ صفحه ندارد)، `respectDoNotTrack` (اندروید چنین سیگنالی ندارد) و `onsite`. پیام درون‌برنامه‌ای همیشه روشن است و در هر `screen()` سنجیده می‌شود.

## متدهای عمومی {#api}

`Segmentic` یک `object` کاتلین است و هر متدش `@JvmStatic` است، پس از جاوا هم static معمولی دیده می‌شود.

```kotlin
val isInitialised: Boolean

fun init(context: Context, options: SegmenticOptions)

fun track(event: String, properties: Map<String, Any?>? = null)
fun screen(name: String, properties: Map<String, Any?>? = null)
fun identify(userId: String, traits: Map<String, Any?>? = null)
fun alias(previousId: String)
fun reset()

fun optOut()
fun optIn()
fun isOptedOut(): Boolean

fun registerDevice(tokens: Map<String, String>, hasGms: Boolean? = null)
fun dismissOnsite()

fun flush()
fun stats(): SegmenticStats?
fun anonymousId(): String?
fun userId(): String?
fun shutdown()
```

نمونه کامل، همان‌طور که یک اپ فروشگاهی صدایش می‌زند:

```kotlin title="CartActivity.kt"
import net.segmentic.sdk.android.Segmentic

// بازدید صفحه، که همان لحظه‌ای است که پیام درون‌برنامه‌ای هم سنجیده می‌شود
Segmentic.screen("cart", mapOf("items" to 3))

Segmentic.track(
    "product_viewed",
    mapOf("product_id" to "DK-991", "price" to 18_500_000, "currency" to "IRR"),
)

// بعد از ورود: alias خودکار ساخته می‌شود و تاریخچه ناشناس به این کاربر می‌چسبد
Segmentic.identify(
    "u_123",
    mapOf("email" to "ali@example.com", "city" to "شیراز"),
)

Segmentic.track("order_completed", mapOf("revenue" to 2_500_000, "currency" to "IRR"))

// خروج از حساب
Segmentic.reset()
```

رفتارهایی که ارزش دانستن دارند:

- **نام خالی نادیده گرفته می‌شود، نه throw.** `track("")` و `screen("")` و `identify("")` یک خط لاگ می‌نویسند و برمی‌گردند. یک فراخوانی تحلیلی هرگز نباید دلیل شکستن صفحه پرداخت مشتری باشد.
- **`identify` اولین بار یک پیام `alias` جلوتر از خودش صف می‌کند.** فقط وقتی `userId` با آنچه از قبل ذخیره شده فرق داشته باشد. بدون آن، هر رویداد پیش از اولین ورود مال یک غریبه است و هر قیفی که از مرز ورود عبور کند برای همیشه عدد اشتباه گزارش می‌دهد. تفصیلش در [هویت](/docs/identity).
- **`reset` یک `anonymousId` تازه می‌سازد**، `userId` را پاک می‌کند و نشست را می‌اندازد. روی گوشی مشترک، خرید نفر بعدی نباید به‌حساب کسی برود که تازه رفته. ولی نشانه «قبلا این اپ را باز کرده» را پاک نمی‌کند: خروج از حساب کسی را کاربر تازه نمی‌کند، و کمپین «اولین اجرا» نباید بعد از هر خروج برگردد.
- **`flush()` هیچ چیزی برنمی‌گرداند.** کار را روی ترد شبکه SDK می‌اندازد و فورا برمی‌گردد. این با SDK وب فرق دارد که یک `Promise` می‌دهد. اگر لازم دارید بدانید چه شد، `stats()` را بخوانید.
- **هر متدی قبل از `init` یک `Log.w` می‌نویسد و برمی‌گردد.** هیچ چیز throw نمی‌شود و هیچ صفی جمع نمی‌شود.
- **`shutdown()` برای اپ نیست.** هر دو executor را می‌بندد و کلاینت را null می‌کند. برای تست‌های خود مشتری است: اپی که دارد کشته می‌شود لازم نیست تمیزکاری کند، و صف از قبل روی دیسک است.

`stats()` این ۹ فیلد را می‌دهد و اگر `init` نشده باشد `null` است:

| فیلد | نوع | چیست |
|---|---|---|
| `queued` | `Int` | چند پیام همین حالا روی دیسک منتظرند |
| `sent` | `Long` | چند پیام Collector پذیرفته است |
| `dropped` | `Long` | چند پیام افتاده، از پر شدن صف یا از پر بودن دیسک یا از رد دائمی سرور |
| `consecutiveFailures` | `Int` | چند شکست پشت‌سرهم |
| `optedOut` | `Boolean` | انصراف داده شده یا نه |
| `durableStorage` | `Boolean` | روی اندروید همیشه `true` است، چون `FileStore` استفاده می‌شود |
| `anonymousId` | `String` | شناسه ناشناس فعلی |
| `userId` | `String?` | کاربر واردشده، یا `null` |
| `devicePending` | `Boolean` | یک ثبت دستگاه هست که هنوز نرسیده و دوباره تلاش می‌شود |

## تردها {#threading}

این بخش همان چیزی است که مشتری حس می‌کند.

- `track`، `screen`، `identify` و بقیه **فورا برمی‌گردند**. نوشتن روی دیسک روی تردی به نام `segmentic-work` انجام می‌شود، پس هیچ فراخوانی تحلیلی روی ترد اصلی کار I/O نیست.
- شبکه ترد **دوم** است، `segmentic-net`. پس یک Collector کند یا خاموش نمی‌تواند `track()` را پشت یک سوکت منتظر نگه دارد.
- هر دو executor تک‌تردی و daemon و با `Thread.MIN_PRIORITY` ساخته می‌شوند. daemon، چون تایمر ما هرگز نباید دلیل زنده ماندن یک پروسه باشد.
- تایمر flush یک `scheduleWithFixedDelay` با فاصله `flushIntervalMs` است، و `init` بلافاصله یک flush هم می‌زند تا هرچه در اجرای قبلی نرسیده بود همان‌جا برود. ثبت دستگاه معلق هم در همان پاس دوباره تلاش می‌شود.
- وقتی صف به `batchSize` می‌رسد، ارسال بلافاصله شروع می‌شود و منتظر تمام شدن بازه نمی‌ماند.

یک جای مشخص هست که استثنا بلعیده می‌شود، و عمدی است: هر کاری که روی تردهای SDK می‌رود داخل یک try/catch است که خطا را در سطح error لاگ می‌کند. این تردها داخل پروسه مشتری‌اند و یک throwable گرفته‌نشده روی ترد پس‌زمینه کل اپ او را پایین می‌آورد. انجام دادن این کار به‌خاطر یک نوشتن ناموفق تحلیلی قابل دفاع نبود.

## صف آفلاین {#queue}

روی شبکه موبایل ایران دستگاه ساعت‌ها آفلاین می‌ماند. هر پیام **قبل از هر تلاش شبکه** روی دیسک نوشته می‌شود، پس کشته شدن اپ، قطع سیگنال و قطعی بک‌اند هیچ داده‌ای نمی‌برد.

شکل ذخیره‌سازی یک خط به‌ازای هر پیام است:

```text
<message_id>\t<پیام، از قبل به JSON کدشده>\n
```

دو چیز از این شکل نتیجه می‌شود و هر دو خود هدف‌اند:

- **هیچ چیز هرگز parse نمی‌شود.** پیام یک بار، هنگام صف شدن، کد شده و همان بایت‌ها به Collector می‌رسند. مقداری که از کدگذاری جان سالم به در برده، نمی‌تواند در رفت و برگشت از دیسک خراب شود.
- **نوشتنی که نصفه قطع شده، دقیقا به اندازه خط آخر هزینه دارد.** هر خط کامل قبل از آن باز هم بارگذاری می‌شود.

هنگام بارگذاری دو بررسی انجام می‌شود: خطی که tab ندارد رد می‌شود، و پیامی که هم‌زمان با `{` شروع و به `}` ختم نشود هم رد می‌شود. بررسی دوم به این خاطر است که نصف یک پیام، اگر فرستاده شود، از سمت Collector به‌عنوان بدنه خراب رد می‌شود و کل بسته پشت سرش را هم با خودش پایین می‌کشد.

**محل ذخیره.** یک فایل به‌ازای هر کلید، در `filesDir/segmentic/` اپ شما. نه cache و نه حافظه خارجی: سیستم‌عامل هر وقت بخواهد cache را پاک می‌کند، و رویدادی که منتظر تمام شدن یک قطعی است cache نیست؛ از دست دادنش یعنی از دست دادن داده مشتری.

نوشتن مستقیم روی مقصد انجام می‌شود، بدون فایل موقت و بدون rename. این بی‌احتیاطی نیست، تصمیم است: `renameTo` روی هر درایور ذخیره‌سازی اندروید نمی‌تواند فایل موجود را جایگزین کند، و `java.nio.file.Files.move` به `API 26` نیاز دارد در حالی که کف این SDK ۲۴ است. به‌جای اجتناب از قطع شدن نوشتن، قطع شدنش بی‌خطر شده است.

**وقتی صف پر می‌شود، از جلو افت می‌کند.** بعد از یک قطعی طولانی، تازه‌ترین رویدادها آن‌هایی هستند که هنوز ارزش داشتن دارند. تعداد افتاده در `stats().dropped` شمرده می‌شود و هرگز بی‌صدا نیست.

سه دلیل افت، که همه در لاگ debug هم می‌آیند:

| دلیل | کی |
|---|---|
| `queue_full` | از `maxQueueSize` رد شد |
| `storage_full` | نوشتن روی دیسک شکست خورد، نصف بافر ریخته شد و دوباره تلاش شد |
| `storage_unavailable` | نوشتن دوم هم شکست خورد. کار در حافظه ادامه می‌یابد و هرچه مانده در خطر است |

**حذف تکراری.** `message_id` یک UUID است که یک بار، هنگام صف شدن، ساخته می‌شود و در هر تلاش مجدد همان می‌ماند. این تمام مبنای امن بودن ارسال دوباره است: بدون آن، ارسال مجدد روی شبکه ضعیف تعداد خرید مشتری را دو برابر می‌کند.

**پاسخ‌های HTTP**، و اینکه SDK با هرکدام چه می‌کند:

| پاسخ | رفتار |
|---|---|
| `2xx` | پذیرفته شد، از صف پاک می‌شود، شمارنده شکست صفر می‌شود |
| `4xx` بجز `429` | **برای همیشه دور ریخته می‌شود**، از صف پاک و در `dropped` شمرده می‌شود، با یک خط لاگ. بدنه‌ای که سرور رد کرده هرگز پذیرفته نمی‌شود و نگه داشتنش هر رویداد پشت سرش را قفل می‌کند |
| `429` | در صف می‌ماند، دوباره تلاش می‌شود |
| `5xx` | در صف می‌ماند، دوباره تلاش می‌شود، backoff اعمال می‌شود |
| کد `0` | یعنی اصلا پاسخ HTTP‌ای نبود: بی‌سیگنال، خطای DNS، captive portal. جدا از کد واقعی نگه داشته می‌شود، چون قاطی کردن این دو همان‌جایی است که یک SDK شروع می‌کند به تلاش ابدی روی یک `400` |

فهرست کامل کدها و معنایشان در [خطاها](/docs/errors) است.

**Backoff** جیتر کامل است: پایه یک ثانیه، سقف پنج دقیقه، و توان قبل از اعمال شدن روی ۲۰ محدود می‌شود تا دستگاهی که ماه‌ها آفلاین بوده نتواند با سرریز به تأخیر منفی برسد. جیتر از خود منحنی مهم‌تر است: وقتی بک‌اند برمی‌گردد، هزاران دستگاهی که هم‌زمان شکست خورده‌اند نباید هم‌زمان دوباره تلاش کنند و دوباره بیندازندش.

**`maxRetries` آهنگ را محدود می‌کند، نه داده را.** بعد از رسیدن به سقف، پیام‌ها همان‌جا در صف می‌مانند تا اجرای بعدی. کاربر شاید فقط در قطار باشد.

**دو flush هم‌زمان نمی‌شود.** flush دوم فورا `0` برمی‌گرداند به‌جای اینکه پشت اولی صف بکشد، چون دو drain هم‌زمان هرکدام همان پیام‌ها را peek می‌کنند و دو بار می‌فرستند. این هم با SDK وب فرق دارد که flushها را زنجیر می‌کند.

**مهلت‌های شبکه**: connect ده ثانیه، read پانزده ثانیه. هر دو تنظیم شده‌اند، چون سوکتی بدون مهلت خواندن روی شبکه موبایل می‌تواند دقیقه‌ها روی یک اتصال نیمه‌باز آویزان بماند، و این روی تردی است که SDK صاحبش است: آویزان شدنش یعنی صف بدون هیچ خطایی جایی، از تخلیه می‌ایستد. بدنه پاسخ حداکثر تا ۸ کیلوبایت خوانده می‌شود، چون یک proxy یا captive portal می‌تواند به POST ما یک مگابایت HTML جواب بدهد.

## آنچه روی سیم می‌رود {#wire}

`POST {apiHost}/v1/batch` با هدر `Authorization: Bearer wk_seg_...` و `Content-Type: application/json; charset=utf-8`.

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

`sent_at` به جلوی پیام از قبل کدشده تزریق می‌شود، نه اینکه پیام دوباره کد شود. پیام شاید روزها پیش کد شده باشد و کد کردن دوباره‌اش یعنی parse کردنش، که این ماژول عمدا بلد نیست. تزریق یک فیلد شناخته‌شده دقیق است، چون نویسنده خودمان همیشه `{"type":` را اول می‌گذارد و بعد از آکولاد فاصله نمی‌گذارد.

nullها و مپ‌های خالی اصلا فرستاده نمی‌شوند. بسته‌ای از بیست رویداد که هرکدام شش آبجکت خالی حمل کنند، روی خط دیتای اندازه‌گیری‌شده بدنه بزرگ‌تری است، و پول آن دیتا را مشتری می‌دهد نه ما.

این بدنه یک نمونه ساختگی نیست. همان بایت‌هایی است که یک دستگاه اندروید ۱۵ در تست آفلاین SDK روی سیم گذاشت و به‌عنوان فایل طلایی در مخزن نشسته است:

```json title="testdata/android-sdk/batch-tail.json"
{
  "sent_at": "2026-08-07T11:01:47.885Z",
  "batch": [
    {
      "sent_at": "2026-08-07T11:01:47.885Z",
      "type": "track",
      "message_id": "ff6447a2-6cc3-48b2-a429-f83cb07e126d",
      "timestamp": "2026-08-07T11:01:10.609Z",
      "anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
      "event": "scripted_event",
      "properties": { "index": 10, "note": "رویداد آزمایشی" },
      "context": {
        "library": { "name": "segmentic-android", "version": "0.1.0" },
        "session_id": "4b3754ac-66cf-4ecf-a700-fc095072c8e5",
        "app": { "name": "net.segmentic.sample", "version": "0.1.0" },
        "os": { "name": "android", "version": "15" },
        "device": {
          "type": "android",
          "manufacturer": "Google",
          "model": "sdk_gphone64_x86_64"
        },
        "screen": { "width": 320, "height": 640, "density": 1 },
        "locale": "en-US",
        "timezone": "Asia/Tehran"
      }
    }
  ]
}
```

پنج نوع پیام وجود دارد: `track`، `identify`، `screen`، `alias` و `page`. مقدار `page` در enum سیم هست ولی هیچ متد عمومی‌ای روی اندروید آن را نمی‌فرستد؛ `page` مال وب است.

`context.library` و `context.session_id` همیشه هستند و **زیر** کانتکست پلتفرم merge می‌شوند، نه رویش. یک جمع‌کننده پلتفرمی نباید بتواند نام کتابخانه‌ای را که پیام را فرستاده عوض کند. این تست خصمانه دارد: یک `platformContext` که عمدا `library` و `session_id` جعلی برمی‌گرداند، و تست تأیید می‌کند هیچ‌کدام روی سیم نمی‌رسد.

کانتکستی که خودکار جمع می‌شود، وقتی `autoContext` روشن است: `app` (نام بسته و نسخه)، `os`، `device` (سازنده و مدل)، `screen` (پیکسل و density)، `locale`، `timezone` و، فقط اگر اپ شما `ACCESS_NETWORK_STATE` را داشته باشد، `network`.

زمان‌ها با محاسبه از epoch ساخته می‌شوند نه با `SimpleDateFormat`. آن کلاس thread safe نیست و این تابع از روی هر تردی صدا زده می‌شود که مشتری `track()` را رویش صدا زده باشد. یک نمونه مشترک زیر بار زمان‌های درهم تولید می‌کند، که همان نوع باگی است که فقط در پروداکشن ظاهر می‌شود و شبیه مشکل سرور به نظر می‌رسد.

نویسنده JSON دست‌نویس است، با سقف عمق ۳۲. `NaN` و بی‌نهایت به‌جای بدنه نامعتبر، `null` می‌شوند. متن فارسی بدون escape عبور می‌کند و newline همیشه escape می‌شود، که همان چیزی است که قالب خطی صف رویش سوار است.

## توکن پوش را اپ شما می‌دهد {#push-token}

این همان چیزی است که معمولا یک ساعت از وقت یک توسعه‌دهنده را می‌گیرد، پس صریح می‌نویسیم: **این SDK توکن پوش را نمی‌گیرد. شما آن را دستش می‌دهید.**

دلیلش تصمیمی است که ارزش دانستن دارد. اپی که پوش می‌فرستد از قبل فایربیس، یا بازار، یا مایکت را با پروژه خودش و نسخه خودش وصل کرده. اگر ما هم توکن می‌گرفتیم، یعنی این SDK داشت نسخه فایربیس را به‌جای شما انتخاب می‌کرد و با نسخه خودتان تصادم می‌کرد. پس شما همان چیزی را که `onNewToken` خودتان داده به ما می‌دهید.

کل سمت مشتری نه خط است:

```kotlin title="MyMessagingService.kt"
package com.example.shop

import com.google.firebase.messaging.FirebaseMessagingService
import net.segmentic.sdk.PushTransport
import net.segmentic.sdk.android.Segmentic

class MyMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
    }
}
```

هر بار که ارائه‌دهنده توکن را می‌چرخاند دوباره صدایش بزنید. ثبت دوباره یک دستگاه تکراری نیست: سرور روی `device_id` بالا می‌نویسد. حالت چرخش همان است که اهمیت دارد، چون توکنی که عوض می‌شود و دوباره ثبت نمی‌شود یعنی کاربری که بی‌صدا از هر کمپینی می‌افتد بیرون، و تنها نشانه‌اش نرخ تحویلی است که در طول ماه‌ها آرام پایین می‌رود.

**نام مسیرها را دقیقا همین‌طور بنویسید.** آن‌ها از `push.Transport` در کد Go برداشته شده‌اند:

```kotlin
object PushTransport {
    const val FCM = "fcm"
    const val BAZAAR = "bazaar"
    const val MYKET = "myket"
    const val MQTT = "mqtt"
}
```

فرستادن هر چیز دیگری غلط املایی‌ای نیست که نادیده گرفته شود: سرور هشدار می‌دهد و توکن را می‌اندازد، و بعد مشتری کمپینی می‌بیند که هر ارسال را موفق گزارش می‌کند و هیچ چیزی تحویل نمی‌دهد. اگر مسیری بفرستید که به اندروید نمی‌رسد، مثلا `apns`، هشدار `transport_not_supported` در پاسخ می‌آید.

`MQTT` را نفرستید. ثابتش در هر دو طرف هست و سرور روی یک ثبت اندروید قبولش می‌کند، ولی هیچ ارائه‌دهنده‌ای برای آن پیاده نشده و هیچ پیامی از آن مسیر بیرون نمی‌رود. امروز فقط `fcm` و `bazaar` و `myket` فرستنده دارند. توکنی که فقط `mqtt` باشد بدون هیچ هشداری ذخیره می‌شود و هرگز تحویل نمی‌گیرد، که بدترین حالت است: نه خطایی، نه هشداری، فقط سکوت.

چند مسیر روی یک دستگاه پشتیبانی می‌شود و سرور تصمیم می‌گیرد کدام تحویل بدهد. گوشی‌ای که بدون Play Services فروخته شده باز هم بازار دارد:

```kotlin
Segmentic.registerDevice(
    mapOf(
        PushTransport.FCM to fcmToken,
        PushTransport.BAZAAR to bazaarToken,
    ),
)
```

اگر اپ شما از قبل `play-services-base` دارد، جواب قطعی را خودتان بدهید. جست‌وجوی خود SDK فقط برای این است که SDK به هیچ وابستگی گوگل نیاز نداشته باشد:

```kotlin
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability

val gms = GoogleApiAvailability.getInstance()
    .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS

Segmentic.registerDevice(mapOf(PushTransport.FCM to fcmToken), hasGms = gms)
```

## ثبت دستگاه {#device-registration}

`POST {apiHost}/v1/devices`، با همان هدر `Authorization: Bearer wk_seg_...`.

فیلدها، به ترتیبی که SDK می‌نویسد:

| فیلد JSON | همیشه هست | از کجا |
|---|---|---|
| `device_id` | بله | `segmentic_install_id`، یک UUID تصادفی در حافظه خصوصی اپ |
| `platform` | بله، همیشه `"android"` | ثابت |
| `user_id` | فقط اگر کاربر واردشده باشد | خود SDK پر می‌کند |
| `anonymous_id` | فقط اگر شناخته شده باشد | خود SDK پر می‌کند |
| `tokens` | فقط اگر خالی نباشد | شما |
| `has_gms` | فقط اگر بدانیم | جست‌وجوی بسته یا مقداری که شما دادید |
| `push_enabled` | فقط اگر بدانیم | `NotificationManager.areNotificationsEnabled()` |
| `app_version` | اگر خواندنی باشد | `PackageManager` |
| `manufacturer` | بله | `Build.MANUFACTURER` |
| `model` | بله | `Build.MODEL` |
| `os_name` | بله، همیشه `"android"` | ثابت |
| `os_version` | بله | `Build.VERSION.RELEASE` |
| `locale` | بله | `Locale.getDefault().toLanguageTag()` |
| `timezone` | بله | `TimeZone.getDefault().id` |
| `sdk_name` | بله، همیشه `"segmentic-android"` | ثابت |
| `sdk_version` | بله، همیشه `"0.1.0"` | ثابت |

**هویت را خود SDK پر می‌کند، نه صداکننده.** پس اپ میزبان نمی‌تواند دستگاهی را به‌نام کاربری ثبت کند که از آن حساب خارج شده است.

بدنه واقعی، از همان اجرای شبیه‌ساز:

```json title="testdata/android-sdk/device.json"
{
  "device_id": "79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2",
  "platform": "android",
  "anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
  "tokens": { "fcm": "scripted-token-not-a-real-one" },
  "has_gms": true,
  "push_enabled": false,
  "app_version": "0.1.0",
  "manufacturer": "Google",
  "model": "sdk_gphone64_x86_64",
  "os_name": "android",
  "os_version": "15",
  "locale": "en-US",
  "timezone": "Asia/Tehran",
  "sdk_name": "segmentic-android",
  "sdk_version": "0.1.0"
}
```

و پاسخ موفق:

```json
{ "status": "ok" }
```

اگر توکنی مسیر اشتباهی داشته باشد، پاسخ باز هم `200` است ولی هشدار می‌آورد و توکن سالم ذخیره می‌شود:

```json
{
  "status": "ok",
  "warnings": [
    {
      "code": "transport_not_supported",
      "field": "apns",
      "message": "transport apns cannot deliver to android"
    }
  ]
}
```

SDK این هشدارها را در logcat می‌نویسد، حتی وقتی پاسخ موفق است. سکوت اینجا همان چیزی است که یک نصب خراب را به کمپینی تبدیل می‌کند که صد درصد ارسال گزارش می‌دهد.

**`has_gms` و `push_enabled` سه‌حالته‌اند و وقتی نمی‌دانیم اصلا فرستاده نمی‌شوند.** نه `false`. سرور «نبودن» را «نامعلوم» می‌خواند و نامعلوم با false یکی نیست. فرستادن `false` جایی که فقط نگاه نکرده‌ایم، کاربری را ساکت می‌کند که خودش چیزی نخواسته بود، و تنها نشانه‌اش مخاطبی است که آرام کوچک می‌شود.

- `has_gms` وقتی بسته Play Services پیدا شود `true` است، روی `NameNotFoundException` (که یک گوشی معمولی ایرانی است، نه خطا) `false` است، و روی هر استثنای دیگری `null`.
- `push_enabled` مقدار `areNotificationsEnabled()` است، و `null` وقتی که اصلا نمی‌شود به `NotificationManager` رسید.

**نتیجه ثبت سه حالت دارد**، و SDK هر سه را جدا می‌کند:

| نتیجه | کی | روی دیسک چه می‌شود |
|---|---|---|
| `REGISTERED` | `2xx` | رکورد معلق پاک می‌شود |
| `REFUSED` | `4xx` بجز `429` | رکورد معلق پاک می‌شود. همان بدنه در هر اجرا به همان شکل رد می‌شود، پس تلاش دوباره حلقه‌ای است که نه تمام می‌شود نه جواب می‌دهد |
| `PENDING` | `429`، `5xx`، یا کد `0` | بدنه در `segmentic_pending_device` نوشته می‌شود و در **هر** flush و **هر** اجرای بعدی دوباره فرستاده می‌شود |

چرا ثبت دستگاه دوباره تلاش می‌شود در حالی که رویداد صف دارد و این ندارد: خود Collector در کامنت کدش نوشته که SDK باید دوباره تلاش کند، چون هیچ‌کس دیگری این کار را نمی‌کند. رویدادی که دیر برسد باز هم همان رویداد است، ولی توکنی که هرگز نمی‌رسد یعنی کسی که موافقت کرده اعلان بگیرد و هیچ‌وقت نمی‌شود به او رسید.

`device_id` عمدا نه شناسه تبلیغاتی است و نه `Settings.Secure.ANDROID_ID`. هر دوی آن‌ها یک نفر را در اپ‌های بی‌ربط شناسایی می‌کنند، که سؤال حریم خصوصی‌ای است که مشتری باید جوابش را بدهد نه ما، و گوگل هم اولی را محدود کرده است. این یک مقدار تصادفی در حافظه خصوصی خود اپ است: تا حذف اپ یا پاک کردن داده‌اش می‌ماند و هیچ‌جای دیگری دنبال کاربر نمی‌رود.

مسیر ثبت دستی، برای هر پلتفرمی که SDK ندارد، در [ثبت دستگاه](/docs/devices) است.

## پیام درون‌برنامه‌ای {#inapp}

بنر و مودالی که در پنل ساخته می‌شود خودکار در اپ کشیده می‌شود. تنها کاری که از شما خواسته می‌شود این است:

```kotlin
Segmentic.screen("cart")
```

بازدید صفحه همان لحظه‌ای است که پیام درون‌برنامه‌ای تصمیم گرفته می‌شود، چون نام صفحه همان چیزی است که قاعده هدف‌گیری با آن مطابقت می‌کند. هیچ گزینه‌ای برای خاموش کردنش نیست.

بقیه‌اش خودش انجام می‌شود، روی سه ترد: گرفتن فهرست روی ترد شبکه، تصمیم که یک خواندن کوچک دیسک است روی همان ترد، و کشیدن روی ترد اصلی با تأخیری که خود کمپین گفته.

**گرفتن فهرست.** `GET {apiHost}/v1/onsite?write_key=...` با هدر `Authorization` هم. کلید در هر دو جا هست، عمدا: شکل query چیزی است که پاسخ را برای یک CDN که به هدر `Authorization` کاری ندارد قابل کش می‌کند. فهرست ۶۰ ثانیه معتبر است، همان عددی که Collector در هدر `Cache-Control` می‌گذارد. شکست کاملا بی‌صداست و فهرست قبلی سر جایش می‌ماند: این کد داخل اپ کس دیگری اجرا می‌شود و خرابی ما باید به «امروز پیامی نیست» تنزل کند، نه به خطایی که کاربر او می‌بیند.

**هدف‌گیری روی دستگاه سنجیده می‌شود، نه روی سرور.** جایگزینش یک درخواست به‌ازای هر بازدید صفحه است، روی مسیر حیاتی اپ مشتری، با تأخیر ما جلوی محتوای او و در دسترس بودن ما جلوی کسب‌وکار او.

قاعده‌ها دقیقا همان‌های SDK وب‌اند و به همان ترتیب اجرا می‌شوند، با این تفاوت‌ها روی گوشی:

| قاعده سمت سرور | روی اندروید با چه مطابقت می‌کند |
|---|---|
| `targeting.url_contains` | نام صفحه‌ای که به `screen()` داده‌اید |
| `targeting.url_not_contains` | همان |
| `targeting.devices` | فقط `mobile` یا `tablet` |
| `targeting.delay_seconds` | تأخیر پیش از کشیدن، محدودشده به بازه صفر تا ۶۰ ثانیه |
| `targeting.new_visitors_only` | آیا این اولین اجرای این نصب است |
| `targeting.returning_only` | برعکس بالا |
| `targeting.logged_in` | سه‌حالته: نبودنش یعنی «فرقی نمی‌کند» |
| `targeting.traits` | ویژگی‌های آخرین `identify` که مقدارشان اسکالر بوده |
| `targeting.scroll_percent` | **پشتیبانی نمی‌شود**، بی‌صدا نادیده گرفته می‌شود |
| `targeting.on_exit_intent` | **پشتیبانی نمی‌شود**، بی‌صدا نادیده گرفته می‌شود |

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

**کلاس دستگاه از `smallestScreenWidthDp` می‌آید و مرزش ۶۰۰ است.** کمتر از آن `mobile` و از آن به بالا `tablet`. روی اندروید `desktop` وجود ندارد. توجه کنید که این با SDK وب فرق دارد، که مرزهایش ۷۶۸ و ۱۰۲۴ پیکسل CSS است. ۶۰۰ مرز خود اندروید برای همین سؤال است، پس کمپین همان‌طور رفتار می‌کند که layoutهای خود اپ.

**سقف تکرار** به این ترتیب اعمال می‌شود و هر شرط قبلی بر بعدی مقدم است: خارج از بازه `starts_at` و `ends_at`؛ سپس «تبدیل شده»، که برای همیشه جلویش را می‌گیرد؛ سپس «بسته شده» به‌شرط اینکه کمپین قابل بستن باشد؛ سپس `max_impressions`؛ سپس `cooldown_hours`. مقدار صفر در دو تای آخر یعنی بی‌سقف.

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

**چه چیزی واقعا کشیده می‌شود.** رندرر با Viewهای ساده کار می‌کند، بدون Compose و بدون XML، چون وابستگی Compose اینجا یعنی وابستگی Compose در بیلد هر مشتری، از جمله آن‌هایی که هنوز روی View هستند، و یعنی انتخاب نسخه Compose به‌جای آن‌ها.

| نوع کمپین | روی اندروید |
|---|---|
| `banner` | کشیده می‌شود |
| `modal` | کشیده می‌شود |
| `slidein` | کشیده می‌شود، **به شکل بنر**. انیمیشنش هنوز مشخص نشده |
| `survey` | **کشیده نمی‌شود** |

نظرسنجی ویجت ورودی و یک جریان سؤال می‌خواهد. رندرر برای نوعی که نمی‌شناسد `false` برمی‌گرداند و کمپین آن‌وقت **نه سقف می‌خورد و نه گزارش می‌شود**: وقتی رندرر آن نوع را یاد گرفت، همان کمپین هنوز به کاربر بدهکار است نه اینکه بی‌صدا سوخته باشد.

هسته یک متد `respond` برای فرستادن جواب نظرسنجی دارد و تست هم دارد، ولی **هیچ چیزی در لایه اندروید صدایش نمی‌زند**. تا وقتی رندرر نظرسنجی نوشته نشده، این متد فقط برای کسی در دسترس است که خودش یک `OnsiteManager` بسازد.

پیام روی content root خود Activity اضافه می‌شود، نه در یک Dialog. یک Dialog پنجره خودش را می‌گیرد، که روی اندروید یعنی به شکل‌هایی که هیچ‌کس نمی‌خواهد از Activity صاحبش عمر بیشتری می‌کند، و با کیبورد جابه‌جا نمی‌شود و insetهای اپ را رعایت نمی‌کند.

جزئیاتی که در عمل به آن‌ها برمی‌خورید:

- هر بار فقط یک پیام. `show()` اول `remove()` را صدا می‌زند.
- متن بدنه در سه خط با «...» بریده می‌شود. بازاریاب بالاخره یک انشا paste می‌کند، و بنری که رشد می‌کند تا کل اپ را بپوشاند از بنر بریده بدتر است.
- بنر پیش‌فرض پایین می‌نشیند، مگر `content.position` برابر `top` باشد. بنر روی نوار ابزار خود اپ ناوبری را می‌پوشاند، و کاربری که نمی‌تواند ناوبری کند اپ را می‌بندد.
- دکمه بستن حداقل ۴۸ dp سطح لمس دارد و `contentDescription` آن «بستن» است.
- رنگ‌های پیش‌فرض: پس‌زمینه `#1F2430`، متن `#F5F7FA`، رنگ تأکید `#2F6FED`. رنگی که parse نشود به پیش‌فرض برمی‌گردد و throw نمی‌کند: بازاریابی که اسم یک رنگ را در فیلد hex تایپ می‌کند نباید اپی را که در آن تبلیغ می‌کند بشکند.
- scrim مودال حتی وقتی کمپین قابل بستن نیست هم clickable است، تا یک لمس از پشت مودالی که رویش نشسته به اپ نیفتد.
- کلیک **قبل از** باز شدن لینک گزارش می‌شود. لینکی که باز نمی‌شود باز هم کلیکی است که مشتری باید در گزارشش ببیند.

یک ایراد واقعی که فقط با نگاه کردن به عکس صفحه پیدا شد و ارزش نوشتن دارد: نسخه اول بنر را چسبیده به لبه پایین می‌گذاشت و نوار حرکتی خط آخر را می‌خورد. دو تلاش با inset listener شکست خورد، چون listener روی ویویی که هنوز attach نشده هرگز صدا زده نمی‌شود و بعد از attach هم به این بستگی دارد که هر والد insetها را پایین بدهد، که یک کتابخانه نمی‌تواند درباره سلسله‌مراتب ویوی کس دیگری فرضش کند. راه‌حل، خواندن مستقیم `rootWindowInsets` در همان لحظه کشیدن است. این از قبل هم مهم بود و حالا مهم‌تر است، چون اندروید ۱۵ هر اپی با `targetSdk` برابر ۳۵ را edge to edge می‌کند.

**گزارش.** `POST {apiHost}/v1/onsite/event` با بدنه‌ای به این شکل، که `action` یکی از `impression`، `dismiss`، `click` یا `convert` است و `user_id` وقتی کاربر ناشناس است اصلا نمی‌آید:

```json
{
  "campaign_id": 42,
  "action": "click",
  "anonymous_id": "711faad0-317b-40aa-81d7-253a39280348",
  "user_id": "u_123"
}
```

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

اگر خودتان بخواهید پیام را از صفحه بردارید:

```kotlin
Segmentic.dismissOnsite()
```

مفهوم کلی و ساختن کمپین در پنل در [پیام درون‌سایتی](/docs/onsite) است.

## انصراف از ردیابی {#consent}

```kotlin
Segmentic.optOut()
Segmentic.optIn()
val stopped = Segmentic.isOptedOut()
```

`optOut()` سه کار می‌کند: پرچم را می‌گذارد و در `segmentic_opt_out` روی دیسک ذخیره‌اش می‌کند، **صف را خالی می‌کند**، و ثبت دستگاه معلق را هم پاک می‌کند.

خالی کردن صف نکته اصلی است. احترام گذاشتن به انصراف فقط برای رویدادهای آینده، در حالی که بی‌سروصدا آنچه از قبل جمع شده تحویل داده شود، انصراف نیست.

بعد از انصراف: هیچ رویدادی صف نمی‌شود، `flush()` بدون هیچ درخواستی برمی‌گردد، `registerDevice` بدون هیچ درخواستی `REFUSED` می‌دهد، و پیام درون‌برنامه‌ای کشیده نمی‌شود. پرچم بعد از بستن و باز کردن اپ هم می‌ماند، چون روی دیسک است.

معادل Do Not Track روی اندروید وجود ندارد، درست هم هست: چنین سیگنالی در سیستم‌عامل نیست. سیاست رضایت و آنچه پنل با آن می‌کند در [رضایت](/docs/consent) است.

## چه چیزی امروز نیست {#not-built}

فهرست صادقانه، تا کسی یک بعدازظهر را صرف گشتن دنبال چیزی نکند که وجود ندارد:

- **`page()`.** روی اندروید وجود ندارد. `screen()` معادلش است.
- **انتساب کمپین.** روی وب یک کلیک با `sg_mid` گرفته می‌شود، یک `message_clicked` می‌سازد و هفت روز روی رویدادهای بعدی سوار می‌شود. روی اندروید **هیچ‌کدام از این‌ها نیست**. تبدیل موبایل امروز به یک پیام نسبت داده نمی‌شود.
- **کمکی برای گرفتن توکن پوش.** فقط `registerDevice`، و توکن را شما می‌دهید.
- **رندر نظرسنجی**، و هر صداکننده‌ای برای `OnsiteManager.respond` در لایه اندروید.
- **تریگرهای عمق اسکرول و قصد خروج.** روی وب هستند، اینجا نه.
- **بازار و مایکت با توکن واقعی.** نگاشت توکنشان پذیرفته می‌شود و سرور مسیرشان را می‌شناسد، ولی هیچ‌کدام هیچ‌وقت با توکن واقعی امتحان نشده. اگر روی این دو فروشگاه پوش می‌فرستید، اولین نفرید.
- **انتشار روی مخزن.** پکیج روی هیچ مخزن عمومی‌ای نیست؛ برای گرفتنش با ما تماس بگیرید.
- **اثبات روی یک اپ مشتری واقعی.** اثبات موجود روی اپ نمونه‌ی خودمان است. بخش [چه چیزی اثبات شده و چطور](/docs/sdk-android#proof) می‌گوید دقیقا چه چیزی پوشش دارد.

## چه چیزی ثابت شده و چطور {#proof}

**۱۰۳ تست کاتلین**، همه روی JVM ساده:

| فایل | تعداد | چه چیزی |
|---|---|---|
| `SegmenticClientTest.kt` | ۳۳ | شکل سیم، حذف تکراری، رفتار `400` و `429` و `503` و بی‌سیگنال، هویت، alias، انصراف، سرریز بافر، ثبت دستگاه و سه نتیجه‌اش، نرمال‌سازی تنظیمات، و تست خصمانه کانتکست پلتفرم |
| `CoreTest.kt` | ۲۲ | نویسنده JSON، تاریخ `ISO 8601`، صف، نشست، backoff |
| `OnsiteTest.kt` | ۲۲ | هدف‌گیری، سقف تکرار، ذخیره رکورد دیده‌شده، کلاس دستگاه |
| `OnsiteManagerTest.kt` | ۲۰ | گرفتن فهرست، parse، کش، تصمیم، گزارش، و خواننده JSON |
| `HttpIntegrationTest.kt` | ۶ | روی یک سوکت واقعی، مقابل `com.sun.net.httpserver.HttpServer` روی localhost |

**۶ تست Go روی بایت‌های واقعی.** فایل‌های `backend/internal/collector/testdata/android-sdk/` فیکسچری نیستند که کسی نوشته باشد تا با parser بخواند. همان بایت‌هایی هستند که یک دستگاه اندروید ۱۵ روی سیم گذاشت: دوازده رویداد در حالی که هیچ‌کس روی پورت گوش نمی‌داد بافر شد، اپ با force-stop کشته شد، و بعد از راه‌اندازی دوباره تحویل شدند. یک Collector ضبط‌کننده درخواست‌ها را عینا نگه داشت.

آن شش تست این‌ها را تأیید می‌کنند:

- بسته اول دقیقا پنج پیام دارد، هر پیام از `model.Normalize` واقعی با **صفر هشدار** رد می‌شود، و `sdk_name` و `sdk_version` و `os_name` و `os_version` همان‌اند که باید.
- `properties.index` به‌شکل عدد می‌رسد نه رشته. اگر SDK صفر را متن فرستاده بود، نگاشت عددی هیچ ورودی‌ای نداشت و هر سگمنت «بزرگ‌تر از» روی آن ویژگی بی‌صدا هرگز مطابقت نمی‌کرد. و `properties.note` برابر «رویداد آزمایشی» است، یعنی فارسی از کدگذاری کاتلین و دیسک و مرگ پروسه و سوکت و parse شدن در Go سالم بیرون آمده.
- فاصله بین زمان اولین رویداد و `sent_at` بسته بیشتر از سی ثانیه و کمتر از ده دقیقه است، پس تصحیح ساعت واقعا کاری برای انجام دادن دارد.
- دوازده رویداد با `batchSize` برابر پنج، دم دقیقا دوتایی می‌گذارد. دم یک‌تایی یا سه‌تایی یعنی `peek` یا `ack` صف اشتباه است.
- بدنه طلایی ثبت دستگاه با صفر هشدار نرمال می‌شود و توکن `fcm` سر جایش می‌رسد.
- `has_gms` و `push_enabled` هر دو به‌شکل اشاره‌گر می‌رسند، یعنی حالت سه‌گانه تا خود سرور حفظ شده.

**اجرای دستی روی شبیه‌ساز اندروید ۱۵**، که مخزن دوباره اجرایش نمی‌کند ولی سه دستور `adb` تکرارش را ممکن می‌کند:

```bash
adb shell am start -n net.segmentic.sample/.MainActivity --ei fire 12
adb shell am force-stop net.segmentic.sample
adb shell am start -n net.segmentic.sample/.MainActivity --ez flush true
```

هر ۱۲ رویداد در بسته‌های پنج و پنج و دو رسیدند، با ۱۲ `message_id` متفاوت و هیچ‌کدام دو بار.

برای سنجیدن پیام درون‌برنامه‌ای، اکسترا `sgscreen` است نه `screen`، چون `am start` نام ساده را برای خودش برمی‌دارد و بی‌صدا می‌بلعد:

```bash
adb shell am start -n net.segmentic.sample/.MainActivity --es sgscreen cart
```

**پوش واقعی**، روی همان شبیه‌ساز با یک پروژه فایربیس دورانداختنی: فایربیس یک توکن ۱۴۲ کاراکتری داد، `onNewToken` آن را به SDK سپرد، SDK روی `POST /v1/devices` ثبتش کرد و یک پیام از FCM v1 فرستاده شد. با اپ در پیش‌زمینه به `onMessageReceived` رسید و با اپ در پس‌زمینه سیستم خودش نوتیف را کشید، هر دو با تیتر و متن فارسی سالم.

نکته‌ای که همان‌جا خودش را نشان داد: پیش از دادن اجازه اعلان، `push_enabled` مقدار `false` رفت و بعد از `pm grant` شد `true`. یعنی حالت سه‌گانه واقعیت سیستم را گزارش می‌کند نه فرضی را که ما کرده باشیم.

**پیام درون‌برنامه‌ای**، مقابل Collectorی که یک کمپین زنده سرو می‌کرد: بنر با تیتر و متن فارسی کشیده شد، گزارش impression روی `POST /v1/onsite/event` رسید، و در اجرای دوم سقف تکرار جلویش را گرفت.

---

# ثبت دستگاه برای اعلان موبایل

> ثبت توکن دستگاه برای پوش موبایل، پوش مرورگر و پیام‌رسان‌ها.
> https://segmentic.net/docs/devices

## دو مسیر، و اینکه کدام را لازم دارید {#two-paths}

> نمودار: تبدیل اشتراک وب، توکن FCM و توکن APNs به مسیرهای قابل دسترس ارسال

ثبت دستگاه یک رویداد نیست. رویدادها از صف رد می‌شوند چون پلتفرم باید انفجار صدهزارتا در ثانیه را جذب کند. ثبت دستگاه شکل مخالف دارد: چند بار در روز برای هر نصب، و باید بلافاصله خواندنی باشد. کسی که اپ را باز می‌کند و دو ثانیه بعد وارد یک سناریوی خوش‌آمد می‌شود، همان لحظه باید قابل ارسال باشد. برای همین `POST /v1/devices` مستقیم روی پایگاه داده می‌نویسد و اصلا وارد صف نمی‌شود.

دو مسیر برای رسیدن به این نقطه هست و هیچ‌کدام جای دیگری را نمی‌گیرد.

| مسیر | برای چه کسی | چه چیزی را خودتان پر می‌کنید |
|---|---|---|
| SDK اندروید | اپ اندروید | فقط توکن، و اگر بخواهید `has_gms` |
| `POST /v1/devices` | iOS، وب، دسکتاپ، اتصال سمت سرور، و هر اپی که وابستگی اضافه نمی‌کند | تمام فیلدها |

مسیر دستی حذف نمی‌شود. SDK منتشرشده‌ای برای iOS وجود ندارد، یک اتصال سمت سرور اصلا دستگاهی ندارد که از آن توکن بگیرد، و بعضی مشتری‌ها هیچ کتابخانه‌ای به اپشان اضافه نمی‌کنند. هر سه حالت به همین یک اندپوینت می‌رسند.

> [!note]
> هر دو مسیر روی هاست ورودی کار می‌کنند: `https://in.segmentic.net` با کلید نوشتن `wk_seg_...`. کلید نوشتن عمومی است و قرار است داخل بسته اپ شما باشد. هاست مدیریت (`https://api.segmentic.net`، با کلید API `sk_seg_...`) هیچ مسیر دستگاهی ندارد: نه ساخت، نه ویرایش، نه حذف، و نه خواندن. تنها جایی که دستگاه‌های یک نفر دیده می‌شوند پنل است، و در [خواندن دستگاه‌های یک پرونده](/docs/devices#reading-back) توضیح داده شده.

روی نصب لوکال، همین آدرس‌ها روی `http://localhost:8080` بالا می‌آیند.

ثبت دستگاه شمارش و سهمیه ندارد. `overQuota` و شمارنده مصرف فقط روی `/v1/track` و خانواده‌اش و `/v1/batch` صدا زده می‌شوند؛ `POST /v1/devices` هیچ‌کدام را صدا نمی‌زند. تعداد دفعاتی که دستگاه‌هایتان را دوباره ثبت می‌کنید روی صورتحساب اثری ندارد.

## مسیر SDK اندروید {#android-sdk}

توکن را خود اپ شما می‌دهد، SDK آن را نمی‌گیرد. این تصمیم عمدی است: اپی که پوش می‌فرستد از قبل فایربیس یا بازار یا مایکت را با پروژه و نسخه خودش وصل کرده. اگر SDK هم خودش توکن می‌گرفت، یعنی نسخه فایربیس را به جای شما انتخاب کرده بودیم و با نسخه خودتان تصادم می‌کردیم. نتیجه‌اش این است که کل SDK اندروید هیچ وابستگی ندارد: نه کتابخانه JSON، نه HTTP، نه AndroidX، نه فایربیس. تنها مجوزی که به اپ اضافه می‌شود `INTERNET` است.

یک بار راه‌اندازی، بعد ثبت از داخل همان جایی که توکن می‌رسد:

```kotlin
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        Segmentic.init(
            this,
            SegmenticOptions(
                writeKey = "wk_seg_...",
                apiHost = "https://in.segmentic.net",
            ),
        )
    }
}

class MyMessagingService : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Segmentic.registerDevice(mapOf(PushTransport.FCM to token))
    }
}
```

چند مسیر روی یک دستگاه، که کدامشان تحویل می‌دهد سمت سرور تصمیم می‌شود:

```kotlin
Segmentic.registerDevice(
    mapOf(
        PushTransport.FCM to fcmToken,
        PushTransport.BAZAAR to bazaarToken,
    ),
)
```

`PushTransport` روی اندروید چهار ثابت دارد: `FCM`، `BAZAAR`، `MYKET`، `MQTT`. ثابت `APNS` روی اندروید وجود ندارد، چون آندروید هرگز نمی‌تواند به آن تحویل بدهد.

اگر اپ شما از قبل به `play-services-base` وابسته است، جواب قطعی را خودتان بدهید. SDK وقتی چیزی به آن ندهید بسته `com.google.android.gms` را پروب می‌کند: پیدا شد `true`، پیدا نشد `false`، هر خطای دیگر `null` یعنی «نمی‌دانم».

```kotlin
val gms = GoogleApiAvailability.getInstance()
    .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
Segmentic.registerDevice(tokens, hasGms = gms)
```

SDK این‌ها را خودش پر می‌کند و شما لازم نیست: `device_id`، `platform`، `has_gms`، `push_enabled`، `app_version`، `manufacturer`، `model`، `os_name`، `os_version`، `locale`، `timezone`، `sdk_name`، `sdk_version`. هویت (`user_id` و `anonymous_id`) را هم هسته پر می‌کند نه فراخوان، تا اپ میزبان نتواند دستگاه را به نام کاربری ثبت کند که از حساب خارج شده.

`device_id` یک UUID تصادفی در حافظه خصوصی خود اپ است. عمدا نه شناسه تبلیغاتی است و نه `Settings.Secure.ANDROID_ID`؛ هر دوی آن‌ها آدم را بین اپ‌های بی‌ربط دنبال می‌کنند و این سوالی است که مشتری باید جوابش را بدهد نه ما. این شناسه تا وقتی زنده است که اپ حذف یا داده‌اش پاک نشود.

این دقیقا بایت‌هایی است که یک گوشی اندروید ۱۵ روی سیم گذاشت. فایل نمونه دست‌نویس نیست؛ با یک collector ضبط‌کننده گرفته شده و فقط از اجرای دوباره روی دستگاه واقعی بازتولید می‌شود:

```json title="بایت‌های واقعی یک نصب اندروید"
{"device_id":"79a1c2c3-a61a-4816-a355-f3d5a0c7ffc2","platform":"android","anonymous_id":"711faad0-317b-40aa-81d7-253a39280348","tokens":{"fcm":"scripted-token-not-a-real-one"},"has_gms":true,"push_enabled":false,"app_version":"0.1.0","manufacturer":"Google","model":"sdk_gphone64_x86_64","os_name":"android","os_version":"15","locale":"en-US","timezone":"Asia/Tehran","sdk_name":"segmentic-android","sdk_version":"0.1.0"}
```

`registerDevice` سه نتیجه دارد و تفاوتشان مهم است:

- `REGISTERED`: ذخیره شد.
- `REFUSED`: یک `4xx` گرفت. بدنه دور ریخته می‌شود، چون همان بدنه دفعه بعد هم به همان شکل رد می‌شود و نگه داشتنش یعنی هر بار بالا آمدن اپ، برای همیشه، همان درخواست ردشده. همین مقدار وقتی هم برمی‌گردد که کاربر انصراف داده باشد، و آن حالت اصلا درخواستی نمی‌فرستد.
- `PENDING`: هر چیز دیگر. روی دیسک نوشته می‌شود و در هر flush و هر بالا آمدن بعدی اپ دوباره تلاش می‌شود.

این سه مقدار را خودتان تحویل نمی‌گیرید. `Segmentic.registerDevice` روی نخ شبکه خودش اجرا می‌شود و چیزی برنمی‌گرداند؛ نتیجه فقط در لاگ‌کت با تگ `segmentic` می‌آید. هسته است که مقدار را برمی‌گرداند و خودش هم آن را دوباره تلاش می‌کند.

فراخوانی دوباره `registerDevice` برای همان دستگاه تکراری حساب نمی‌شود؛ سرور روی `device_id` جایگزینی می‌کند. هر بار که ارائه‌دهنده توکن را عوض کرد دوباره صدایش بزنید.

جزئیات نصب و بقیه سطح SDK در [SDK اندروید](/docs/sdk-android) است.

## ثبت دستگاه با `POST /v1/devices` {#register}

یک درخواست، یک دستگاه. اندپوینت دسته‌ای وجود ندارد.

```bash title="ثبت یک نصب اندروید با دو مسیر"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u_123",
    "platform": "android",
    "tokens": { "fcm": "fcm-tok", "bazaar": "bazaar-tok" },
    "model": "Xiaomi Redmi Note 12",
    "timezone": "Asia/Tehran"
  }'
```

پاسخ `200`، عینا همین و نه چیز بیشتری، چون `warnings` وقتی خالی است اصلا در بدنه نمی‌آید:

```json
{"status":"ok"}
```

آیفون همان اندپوینت است با پلتفرم و مسیر دیگر:

```bash title="ثبت یک نصب iOS از کد خودتان"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "F7A1C2C3-A61A-4816-A355-F3D5A0C7FFC2",
    "user_id": "u_9137",
    "platform": "ios",
    "tokens": { "apns": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" },
    "push_enabled": true,
    "app_version": "3.4.0",
    "model": "iPhone13,2",
    "os_name": "ios",
    "os_version": "17.4",
    "locale": "fa-IR",
    "timezone": "Asia/Tehran"
  }'
```

```json
{"status":"ok"}
```

کلید نوشتن را سه جور می‌شود داد و هر سه معتبرند:

- `Authorization: Bearer wk_seg_...`
- `X-Segmentic-Key: wk_seg_...`
- پارامتر کوئری `?write_key=wk_seg_...` (برای فراخوان‌هایی مثل `sendBeacon` که هدر نمی‌توانند بگذارند)

نکته‌های ترابری: بدنه JSON است و بدون توجه به `Content-Type` پارس می‌شود؛ سقف بدنه ۵ مگابایت (`5 << 20` بایت) است؛ پاسخ همیشه `application/json; charset=utf-8` است. CORS باز است (`Access-Control-Allow-Origin: *`، متدهای `POST, OPTIONS`، هدرهای `Content-Type, Authorization, X-Segmentic-Key`، عمر پیش‌پرواز `86400`) ولی `credentials` هرگز اجازه داده نمی‌شود.

`tenant_id` و `app_id` اگر در بدنه بفرستید نادیده گرفته می‌شوند. هر دو از کلید نوشتن می‌آیند. تستی هست که `"tenant_id": 999` می‌فرستد و بررسی می‌کند دستگاه هنوز زیر مستاجر واقعی کلید ذخیره شده باشد.

## هر فیلد بدنه {#fields}

| فیلد | نوع | اجباری | رفتار و پیش‌فرض |
|---|---|---|---|
| `device_id` | رشته | بله | فاصله‌های دو سر حذف می‌شود. خالی یا بلندتر از `256` بایت کل درخواست را رد می‌کند |
| `platform` | رشته | بله | با بی‌تفاوتی به بزرگی و کوچکی حروف و با نام‌های مستعار پارس می‌شود. جدول [پلتفرم‌ها](/docs/devices#platforms) |
| `user_id` | رشته | یکی از این دو لازم است | فاصله‌گیری، سپس برش در `256` بایت |
| `anonymous_id` | رشته | یکی از این دو لازم است | فاصله‌گیری، سپس برش در `256` بایت |
| `tokens` | شیء، نگاشت نام مسیر به توکن | نه، ولی قانون «چیزی برای ذخیره نماند» را ببینید | کلیدها کوچک و فاصله‌گیری می‌شوند، مقدارها فاصله‌گیری. سقف هر توکن `4096` بایت |
| `push_provider` | رشته | نه | شکل قدیمی تک‌مسیره، جفت با `push_token` |
| `push_token` | رشته | نه | شکل قدیمی تک‌مسیره |
| `has_gms` | بولین یا `null` | نه | سه‌حالته. نبودنش یعنی نامعلوم، که `false` نیست |
| `push_enabled` | بولین یا `null` | نه | سه‌حالته. نبودنش یعنی نامعلوم، که «مجاز» خوانده می‌شود |
| `app_version` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `manufacturer` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `model` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `os_name` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `os_version` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `locale` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `timezone` | رشته | نه | فاصله‌گیری، برش در `256` بایت. همین است که «ساعت ۹ صبح بفرست» و ساعت سکوت را به ساعت خود گیرنده معنا می‌کند |
| `sdk_name` | رشته | نه | فاصله‌گیری، برش در `256` بایت |
| `sdk_version` | رشته | نه | فاصله‌گیری، برش در `256` بایت |

برش روی مرز نویسه انجام می‌شود، پس یک فیلد فارسی هرگز به UTF-8 نامعتبر تبدیل نمی‌شود. تست این را با ۲۵۶ نسخه از «ش» می‌سنجد.

شکل قدیمی تک‌مسیره هنوز کار می‌کند و قرار نیست حذف شود، چون مشتری‌ها نسخه SDK را سال‌ها ثابت نگه می‌دارند و ارتقا نباید شرط دریافت‌کردن باشد:

```bash title="شکل قدیمی، هنوز پذیرفته می‌شود"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-old",
    "user_id": "u_123",
    "platform": "android",
    "push_provider": "bazaar",
    "push_token": "legacy"
  }'
```

```json
{"status":"ok"}
```

اگر هر دو شکل را بفرستید، اول جفت قدیمی اعمال می‌شود و بعد `tokens` رویش می‌نویسد، پس نگاشت غنی‌تر برنده است.

### قانون «چیزی برای ذخیره نماند» {#nothing-to-store}

`tokens` خالی به‌تنهایی خطا نیست؛ بستگی به `push_enabled` دارد.

- بدون توکن و `push_enabled` غایب یا `true`: خطای `400` با پیام `device: registration carries no usable token`. فراخوان هیچ کاری نکرده، پس ذخیره‌اش فقط جدول را بزرگ می‌کرد.
- بدون توکن و `push_enabled: false`: پاسخ `200` و ردیف ذخیره می‌شود. این یک تغییر وضعیت واقعی است: کاربر اعلان را خاموش کرده و باید ثبت شود.

حالت دوم دقیقا همان چیزی است که بعد از خاموش‌کردن اعلان در تنظیمات سیستم عامل باید بفرستید:

```bash title="کاربر اعلان را خاموش کرده"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u_123",
    "platform": "android",
    "push_enabled": false
  }'
```

```json
{"status":"ok"}
```

## پلتفرم‌ها و مسیرهایی که به هرکدام می‌رسند {#platforms}

`platform` اجباری است و پیش‌فرض ندارد. جا انداختنش خطای `400` می‌دهد.

| پلتفرم | املاهای پذیرفته‌شده | مسیرهای پذیرفته‌شده هنگام ثبت | کمپین واقعا تحویل می‌دهد؟ |
|---|---|---|---|
| `android` | `android` | `fcm`، `bazaar`، `myket`، `huawei`، `mqtt` | بله، با `fcm` یا `bazaar` یا `myket` یا `huawei` |
| `ios` | `ios`، `iphone`، `ipad` | `apns`، `mqtt` | بله، با `apns` |
| `web` | `web`، `browser`، `webapp` | `webpush` | بله، با `webpush` |
| `windows` | `windows`، `win`، `win32`، `win64` | `webpush` | نه. مسیر پیش‌فرضی برایش تعریف نشده |
| `macos` | `macos`، `mac`، `mac os`، `osx`، `darwin` | `webpush` | نه. مسیر پیش‌فرضی برایش تعریف نشده |
| `linux` | `linux` | `webpush` | نه. مسیر پیش‌فرضی برایش تعریف نشده |
| `server` | `server`، `backend`، `api` | هیچ‌کدام | ثبت با `400` رد می‌شود |

نام‌ها با بی‌تفاوتی به بزرگی و کوچکی حروف و بعد از حذف فاصله‌های دو سر پارس می‌شوند؛ `" Android "` می‌شود `android`. همین برای کلیدهای `tokens` هم صادق است: `" FCM "` می‌شود `fcm`.

سه پلتفرم دسکتاپ عمدا زیر `web` تا نشده‌اند، تا «چند تا نصب مک‌اواس داریم» جوابی داشته باشد. اصلا هم به این دلیل اضافه شدند که یک مشتری واقعی این‌ها را داشت و ایمپورتش شکست.

> [!warn]
> `windows`، `macos` و `linux` ثبت می‌شوند ولی امروز پوش نمی‌گیرند. جدول ترتیب مسیرها فقط برای `android`، `ios` و `web` مدخل دارد، و روتر برای پلتفرمی که مدخل ندارد هیچ مسیری تولید نمی‌کند. نتیجه هنگام ارسال `push: no usable transport for this device` است.

`server` رد می‌شود نه ذخیره. یک بک‌اند اصلا دستگاهی ندارد، و ردیفی که هیچ پوشی به آن نمی‌رسد در تمام عددهای «قابل دسترسی» که به مشتری نشان می‌دهیم شمرده می‌شد. یعنی `platform: "api"` یا `platform: "backend"` خطای `400` می‌گیرد.

پلتفرم ناشناس و مسیر ناشناس دو رفتار کاملا متفاوت دارند:

- پلتفرم ناشناس (مثلا `blackberry` یا `symbian`) کل درخواست را رد می‌کند. هیچ‌چیز ذخیره نمی‌شود.
- مسیر ناشناس (مثلا `pigeon`) فقط همان یک توکن را با یک هشدار `transport_not_supported` دور می‌ریزد و بقیه ثبت ادامه پیدا می‌کند.

مسیر `mqtt` پذیرفته و ذخیره می‌شود و هرگز تحویل نمی‌دهد. ثابتش تعریف شده و در ترتیب پیش‌فرض هم هست، ولی هیچ پیاده‌سازی ارائه‌دهنده MQTT در کل مخزن وجود ندارد، و روتر مسیری را که ارائه‌دهنده ندارد کنار می‌گذارد. روی `mqtt` حساب نکنید.

## شکل توکن هر مسیر {#tokens}

| مسیر | چه چیزی بفرستید | پاکسازی سمت سرور |
|---|---|---|
| `fcm` | همان رشته‌ای که `onNewToken` داد | فقط فاصله‌گیری دو سر |
| `apns` | هگز حروف کوچک | فاصله‌گیری، حذف `<` و `>` از دو سر، حذف تمام فاصله‌ها، کوچک‌کردن حروف |
| `bazaar` | توکنی که سرویس پوش کافه بازار داد | فقط فاصله‌گیری دو سر |
| `myket` | توکنی که سرویس پوش مایکت داد | فقط فاصله‌گیری دو سر |
| `huawei` | توکنی که HMS Push Kit داد | فقط فاصله‌گیری دو سر |
| `webpush` | آدرس `endpoint` اشتراک مرورگر | فقط فاصله‌گیری دو سر |
| `mqtt` | پذیرفته می‌شود، تحویل نمی‌دهد | فقط فاصله‌گیری دو سر |

پاکسازی APNs تزئینی نیست. APIهای قدیمی iOS توکن را به شکل `<a1b2 c3d4>` رشته می‌کنند و فرستادن همان، برای همیشه، در هر پیام، از سمت اپل رد می‌شود؛ و کمپین گزارش می‌دهد صددرصد ارسال شد. تست این را می‌سنجد: `" <A1B2 C3D4 E5F6> "` تبدیل می‌شود به `a1b2c3d4e5f6`. توجه کنید این پاکسازی فقط برای مسیر `apns` انجام می‌شود.

هیچ اعتبارسنجی دیگری روی شکل توکن انجام نمی‌شود. تنها دو بررسی این است که بعد از فاصله‌گیری خالی نباشد و از `4096` بایت بلندتر نباشد. نه پیشوند، نه مجموعه نویسه، نه بازه طول. توکن بدشکل FCM اینجا پذیرفته می‌شود و هنگام ارسال از خود گوگل رد می‌گیرد.

اگر SDK اپل به شما `Data` می‌دهد، خودتان به هگز تبدیلش کنید. رشته‌کردن با `description` همان `<a1b2 c3d4>` را می‌سازد:

```swift title="تبدیل درست توکن APNs به رشته"
func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
    register(apnsToken: hex)   // your own POST /v1/devices call
}
```

## اجازه اعلان و پلی سرویسز: هر دو سه‌حالته‌اند {#permission}

نبودن یک فیلد با `false` بودنش یکی نیست، و این دو فیلد جایی هستند که این تفاوت پول و مخاطب برایتان خرج می‌کند.

`push_enabled` اجازه اعلان در سطح سیستم عامل است:

| مقدار در JSON | معنی | اثر روی هدف‌گیری |
|---|---|---|
| فیلد غایب، یا `null` | نامعلوم؛ SDK آنقدر قدیمی است که گزارش نمی‌دهد | «مجاز» خوانده می‌شود |
| `true` | کاربر اجازه داده | مجاز |
| `false` | کاربر اعلان را در تنظیمات خاموش کرده | از هر کمپینی حذف می‌شود |

نامعلوم عمدا «مجاز» خوانده می‌شود: ساکت‌کردن کاربری به‌خاطر اینکه نسخه اپش قدیمی است، هر مخاطبی را بی‌صدا آب می‌کند. کوئری تحویل شرط `AND d.push_enabled` دارد و دو ایندکس جستجو هم روی همین شرط جزئی‌اند، پس حذف دو جا اعمال می‌شود.

نکته ذخیره‌سازی که به آن برمی‌خورید: ستون `push_enabled BOOLEAN NOT NULL DEFAULT TRUE` است. یعنی سه‌حالته موقع نوشتن جمع می‌شود. روی درج `COALESCE($8, TRUE)` و روی به‌روزرسانی `COALESCE($8, devices.push_enabled)`. نامعلوم برای ردیف تازه `true` می‌شود و ردیف موجود را دست‌نخورده می‌گذارد، چون سکوت SDK نباید چیزی را که نسخه جدیدتر قبلا گفته پاک کند.

`has_gms` گزارش SDK از سالم بودن گوگل پلی سرویسز است:

| مقدار در JSON | معنی | اثر |
|---|---|---|
| غایب یا `null` | SDK نگاه نکرده | روتر فرض می‌کند احتمالا هست و FCM را امتحان می‌کند |
| `true` | پلی سرویسز سالم به نظر رسید | FCM انتخاب اول |
| `false` | پلی سرویسز نیست | FCM هنگام ارسال کاملا کنار گذاشته می‌شود، و ثبت هشدار `fcm_without_gms` می‌دهد |

توکن FCM حتی وقتی `has_gms: false` است ذخیره می‌شود، چون پلی سرویسز بعدا می‌تواند نصب شود و دور ریختن توکن آن بازیابی را غیرممکن می‌کرد.

> [!danger]
> وقتی منظورتان «بررسی نکردم» است، `false` نفرستید. فیلد را اصلا نگذارید. `push_enabled: false` دستگاه را از هر کمپینی بیرون می‌برد و `has_gms: false` تا ثبت بعدی که خلافش را بگوید FCM را برای آن دستگاه خاموش می‌کند.

روی iOS اصلا `has_gms` نفرستید. آنجا بی‌معنی است و SDK آیفون هم نمی‌فرستد.

## هشدارها {#warnings}

هشدار یعنی چیزی را پذیرفتیم ولی تغییرش دادیم. آرایه `warnings` در پاسخ `200` هم می‌آید و در پاسخ `400` هم. تنها چهار کد وجود دارد.

| `code` | `field` | چه وقت | توکن چه می‌شود |
|---|---|---|---|
| `empty_token` | نام مسیر | توکن خالی یا فقط فاصله بود | دور ریخته می‌شود |
| `token_too_long` | نام مسیر | توکن از `4096` بایت بلندتر بود | دور ریخته می‌شود |
| `transport_not_supported` | نام مسیر | مسیر از نظر فیزیکی نمی‌تواند به آن پلتفرم برسد، یا اصلا نام مسیر ناشناس است | دور ریخته می‌شود |
| `fcm_without_gms` | `fcm` | توکن FCM روی دستگاهی که `has_gms: false` گزارش کرده | نگه داشته می‌شود |

متن `message` هرکدام به ترتیب: `token was empty and has been ignored`، `token exceeds the maximum length and has been ignored`، `transport <t> cannot deliver to <platform>`، `device reports no Play Services; FCM will not be used for it`.

این نمونه واقعی است. یک نصب اندروید که هم توکن APNs فرستاده و هم FCM:

```bash title="یک توکن درست و یک توکن اشتباه"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev-1",
    "user_id": "u",
    "platform": "android",
    "tokens": { "apns": "wrong", "fcm": "right" }
  }'
```

پاسخ `200`. توکن قابل استفاده ذخیره شده و آن یکی نه:

```json
{"status":"ok","warnings":[{"code":"transport_not_supported","message":"transport apns cannot deliver to android","field":"apns"}]}
```

> [!warn]
> پاسخ `200` را بخوانید، فقط به کدش نگاه نکنید. توکن اشتباه به‌آرامی دور ریخته می‌شود و تنها جایی که این را می‌فهمید همین آرایه است. تفاوتش این است که مشتری یک هفته دنبال نرخ تحویل صفر بگردد، یا اولین بار که اندپوینت را صدا می‌زند مشکل را ببیند.

## ردها و کدهای وضعیت {#errors}

پیام `400` عینا متن خطای اعتبارسنجی است، و هشدارها روی شکست هم همراه می‌آیند. بدون آن‌ها، کسی که از یک بیلد اندروید توکن APNs فرستاده فقط «توکن قابل استفاده‌ای نیست» را می‌بیند و هیچ سرنخی ندارد.

| وضعیت | `message` | چه وقت |
|---|---|---|
| `400` | `device: device_id is required` | `device_id` نیامده، بعد از فاصله‌گیری خالی است، یا از `256` بایت بلندتر است |
| `400` | `device: platform must be one of android, ios, web, windows, macos, linux` | `platform` نیامده، ناشناس است، یا به `server` می‌رسد |
| `400` | `device: user_id or anonymous_id is required` | هر دو خالی‌اند |
| `400` | `device: registration carries no usable token` | هیچ توکن قابل استفاده‌ای نماند و اعلان صریحا خاموش اعلام نشده |
| `400` | `malformed JSON` | بدنه JSON نیست |
| `401` | `missing write key` | هیچ کلیدی در هدر یا کوئری نبود |
| `401` | `invalid write key` | کلید ناشناس، باطل‌شده، یا معلق |
| `413` | `request body too large` | بدنه از ۵ مگابایت گذشت |
| `503` | `cannot verify the write key right now; retry` | خود جستجوی کلید شکست خورد. با هدر `Retry-After: 5` |
| `503` | `temporarily unavailable, please retry` | نوشتن دستگاه در پایگاه داده شکست خورد |
| `405` | (بدون بدنه JSON) | این نصب اصلا انبار دستگاه ندارد، پس مسیر ثبت نشده |

نمونه یک `400` کامل، از تستی که پلتفرم `ios` را با توکن `fcm` می‌فرستد. ترتیب کلیدها همین است: `status`، بعد `warnings`، بعد `message`:

```json
{"status":"error","warnings":[{"code":"transport_not_supported","message":"transport fcm cannot deliver to ios","field":"fcm"}],"message":"device: registration carries no usable token"}
```

> [!danger]
> `401` را دائمی بخوانید و `503` را موقت، دقیقا همان کاری که SDKها می‌کنند. جستجوی کلید وقتی خودش خراب است `503` می‌دهد نه `401`، و دلیلش اندازه‌گیری شده است: وقتی این نقطه `401` جواب می‌داد، با پایگاه داده خاموش، هشت از هشت رویداد `401` گرفتند، یعنی یک قطعی زیرساخت در سمت مشتری رویداد نابود می‌کرد در حالی که لاگ خودش می‌گفت کلید API نامعتبر است.

سه تله که اولین بار توسعه‌دهنده را زمین می‌زنند:

- `device_id` بلندتر از `256` بایت پیام `device_id is required` می‌گیرد، با اینکه یکی فرستاده‌اید. پیام گمراه‌کننده است.
- `POST /v1/devices/unregister` با بدنه خراب هم دقیقا همان `device_id is required` را می‌دهد. JSON خرابتان به‌عنوان «شناسه نفرستادی» گزارش می‌شود.
- `405` روی `/v1/devices` در محیط استیجینگ یک واقعیت پیکربندی است نه اشکال بدنه شما. وقتی انبار دستگاه وصل نباشد، مسیر اصلا ثبت نمی‌شود؛ الگوی `OPTIONS /v1/` کل مسیرهای زیر `/v1/` را ادعا می‌کند، پس سرور مسیر را می‌شناسد و متد را نه.

هر پاسخ این اندپوینت‌ها، چه موفق و چه ناموفق، هدر `X-Segmentic-Trace` دارد: شانزده رقم هگز. در هیچ بدنه‌ای تکرار نمی‌شود، پس اگر آن را لاگ نکنید از دست می‌رود، و برای پیگیری یک درخواست تنها چیزی است که به کار ما می‌آید.

فهرست کامل قرارداد خطاها در [خطاها](/docs/errors) است.

## همان دستگاه دوباره، همان توکن جای دیگر {#same-device}

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

**ثبت دوباره همان دستگاه.** ثبت یک upsert روی `(tenant_id, device_id)` است، و دو قانون در انبار اعمال می‌شود نه در فراخوان:

- هرگز پاک نکن. فیلدی که SDK نفرستاده مقدار ذخیره‌شده‌اش را نگه می‌دارد. هر ستون رشته‌ای از `COALESCE(NULLIF(EXCLUDED.x, ''), devices.x)` رد می‌شود. SDKها حقایق دستگاه را از چند جا و در زمان‌های مختلف گزارش می‌کنند، پس اگر سکوت را «پاکش کن» می‌خواندیم، توکن FCM در هر بار باز شدن اپ توکن بازار را پاک می‌کرد.
- توکن را بگیر. اگر نصب دیگری همان توکن را دارد، از دست داده است.

همچنین `last_seen_at` همیشه جلو می‌رود، و `revoked_at` به `NULL` برمی‌گردد چون نصب دوباره یک دستگاه باطل‌شده را زنده می‌کند. توکنی که بازنشسته شده بود `retired_at` صفر می‌شود، چون برگشتن یک توکن بازنشسته یعنی اپ دوباره نصب شده و مسیر زنده است.

**همان توکن روی دستگاه دیگر.** این مهم‌ترین قانون حذف تکراری اینجاست. بعد از بازیابی از بکاپ یا نصب دوباره، ارائه‌دهنده می‌تواند همان توکن را به یک `device_id` جدید بدهد. اگر هر دو ردیف زنده بمانند، هر کمپین به آن آدم دو بار تحویل می‌دهد، و این شبیه اشکال در اپ مشتری به نظر می‌رسد نه در پلتفرم ما. پس ثبت، قبل از درج، هر دارنده دیگر آن توکن را حذف می‌کند:

```sql
DELETE FROM device_tokens
WHERE tenant_id = $1 AND transport = $2 AND token = $3 AND device_id <> $4
```

این حذف داخل همان تراکنش upsert اجرا می‌شود، و یک ایندکس یکتا آن را از «تا حد امکان» به «غیرقابل مذاکره» تبدیل می‌کند:

```sql
CREATE UNIQUE INDEX idx_device_tokens_unique
    ON device_tokens (tenant_id, transport, token)
    WHERE retired_at IS NULL
```

**دو کاربر روی یک گوشی.** ردیف دستگاه با نصب کلید می‌خورد، نه با آدم. یک گوشی در عمرش چند حساب را می‌بیند و یک آدم چند گوشی دارد؛ اگر با کاربر کلید می‌خورد، پیام‌های حساب قبلی به هرکسی که بعد وارد می‌شود می‌رسید. با `POST /v1/devices` بعدی که `user_id` تازه دارد، upsert همان `user_id` را جایگزین می‌کند: ورود جدیدتر برنده است. جدا کردن حساب قبلی کار [خروج از حساب](/docs/devices#unregister) است و اگر آن را صدا نزنید، تا ثبت بعدی حساب قبلی همچنان چسبیده است.

**یک کاربر با چند دستگاه.** دو جا محدود می‌شود.

- سقف تعداد نصب‌هایی که یک نفر روی آن‌ها دریافت می‌کند: پیش‌فرض ۵، از `DELIVERY_DEVICES_PER_USER`. کسی که پنج بار گوشی عوض کرده هنوز پنج ردیف دارد و بدون سقف همان اعلان را پنج بار می‌گیرد، که اسپم خوانده می‌شود و سریع‌ترین راه از دست دادن اجازه پوش است.
- نصب‌های کهنه کنار گذاشته می‌شوند: پیش‌فرض ۱۸۰ روز، از `DELIVERY_STALE_DEVICE`.

سقف دستگاه می‌شمارد نه ردیف توکن. گوشی با سه مسیر یک گیرنده است. ترتیب `last_seen_at DESC` است، تازه‌ترین نصب اول.

**نصب ناشناس.** ثبتی که فقط `anonymous_id` دارد پذیرفته و ذخیره می‌شود، ولی امروز هدف هیچ کمپینی نمی‌شود. کوئری تحویل فقط روی `d.user_id = ANY($2)` انتخاب می‌کند و هیچ مسیر کدی در انبار وجود ندارد که دستگاه را با `anonymous_id` بخواند. پس پوش به یک نصب هنوز شناسایی‌نشده وجود ندارد. اگر کمپین خوش‌آمد برای کاربر ثبت‌نام‌نکرده می‌خواهید، امروز جوابی ندارد؛ باید اول [شناسایی](/docs/identity) اتفاق بیفتد.

## خروج از حساب و حذف نصب {#unregister}

خروج از حساب، حذف نصب نیست. دو کار متفاوت‌اند و یک اندپوینت با یک پرچم آن‌ها را از هم جدا می‌کند.

این بیش از آنچه به نظر می‌رسد اهمیت دارد. روی یک گوشی مشترک، چسبیده ماندن حساب قبلی یعنی نفر بعدی به‌روزرسانی سفارش یک نفر دیگر را می‌گیرد؛ از آن اشکال‌هایی که قرارداد را تمام می‌کند.

| فیلد | نوع | اجباری | معنی |
|---|---|---|---|
| `device_id` | رشته | بله | نصبی که روی آن عمل می‌شود |
| `user_id` | رشته | نه | وقتی بیاید، فقط اگر همان کاربر چسبیده باشد جدا می‌کند |
| `revoked` | بولین | نه، پیش‌فرض `false` | `false` خروج از حساب، `true` نصب را رفته علامت می‌زند |

```bash title="خروج از حساب"
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id": "dev-1", "user_id": "u_123"}'
```

```json
{"status":"ok"}
```

```bash title="نصب رفته است"
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id": "dev-1", "revoked": true}'
```

```json
{"status":"ok"}
```

در پایگاه داده دقیقا این اتفاق می‌افتد. خروج از حساب:

```sql
UPDATE devices SET user_id = '', last_seen_at = now()
WHERE tenant_id = $1 AND device_id = $2
  AND ($3 = '' OR user_id = $3)
```

ردیف عمدا زنده می‌ماند. توکن هنوز معتبر است، آدم ممکن است دوباره وارد شود، و حذف ردیف باعث می‌شد ثبت بعدی مثل یک نصب کاملا تازه دیده شود، که شمارش نصب و بازگشت را خراب می‌کند. `user_id` خالی در درخواست یعنی «هرکه چسبیده را جدا کن».

و حذف نصب:

```sql
UPDATE devices SET
    revoked_at = now(),
    revoked_reason = 'unregistered',
    first_uninstalled_at = COALESCE(first_uninstalled_at, now())
WHERE tenant_id = $1 AND device_id = $2 AND revoked_at IS NULL
```

دلیل `unregistered` است نه `uninstalled`، چون این SDK است که unregister را صدا می‌زند و در عمل خیلی بیشتر خروج از حساب است تا حذف اپ. یکی شمردن این دو، هر خروج از حساب را در گزارش حذف نصب شبیه ریزش نشان می‌داد.

هیچ‌کدام از این دو، توکن‌ها را بازنشسته نمی‌کند. دستگاه باطل‌شده با شرط `d.revoked_at IS NULL` از ارسال کنار می‌رود.

پاسخ‌ها: `200` با `{"status":"ok"}`؛ `400` با `{"status":"error","message":"device_id is required"}` وقتی بدنه JSON نیست یا `device_id` خالی است؛ `503` با پیام موقت وقتی نوشتن شکست بخورد؛ و همان `401` و `503` احراز هویت بخش قبل.

## معادل مرورگر: اشتراک وب‌پوش {#webpush}

مرورگر توکن ندارد، اشتراک دارد. معادل `POST /v1/devices` روی وب دو اندپوینت جدا است.

```bash title="ثبت اشتراک مرورگر"
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/abc123",
      "p256dh": "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
      "auth": "tBHItJI5svbpez7KI4CCXg"
    }
  }'
```

```json
{"status":"ok"}
```

شکل تخت هم پذیرفته می‌شود، یعنی `endpoint` و `p256dh` و `auth` در سطح بالا. شکل تودرتو وجود دارد چون همان شکل خود Push API است: صفحه می‌تواند عینا چیزی را که مرورگر داده پست کند، بدون اینکه بازش کند و مهم‌تر، بدون اینکه کلیدها را دوباره کدگذاری کند. رشته `base64` که یک تابع کمکی خوش‌نیت رمزگشایی و دوباره کدگذاری کرده، کلاسیک‌ترین راهی است که یک اشتراک بی‌صدا روی ماشین گیرنده از رمزگشایی می‌افتد.

| فیلد | نوع | اجباری | معنی |
|---|---|---|---|
| `user_id` | رشته | بله | اشتراک به نام یک آدم ذخیره می‌شود |
| `endpoint` | رشته | بله | آدرس سرویس پوش این نصب مرورگر. داشتنش برای ارسال کافی است، پس مثل یک راز با آن رفتار می‌شود و هرگز به کلاینت برنمی‌گردد |
| `p256dh` | رشته | بله | کلید عمومی مرورگر، `base64url`، نقطه فشرده‌نشده `P-256` |
| `auth` | رشته | بله | راز مشترک ۱۶ بایتی که مرورگر ساخته |

هر چهار تا لازم‌اند. نبودن هرکدام `400` با `{"status":"error","message":"user_id and a complete subscription are required"}` می‌دهد و هیچ‌چیز ذخیره نمی‌شود: `endpoint` بدون کلید غیرقابل استفاده است، بدنه رمز نمی‌شود، و ذخیره‌اش به‌جای یک اتصال خراب، یک گیرنده همیشه‌ناموفق نشان می‌داد. هدر `User-Agent` درخواست هم کنارش ذخیره می‌شود.

لغو اشتراک فقط `endpoint` می‌خواهد:

```bash title="لغو اشتراک"
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"endpoint": "https://fcm.googleapis.com/fcm/send/abc123"}'
```

```json
{"status":"ok"}
```

هیچ `user_id` لازم نیست و هیچ‌کدام بررسی نمی‌شود، و دلیلش صریح است: `endpoint` راز خود اشتراک است، داشتنش از قبل برای ارسال به آن مرورگر کافی است، پس خواستن چیز بیشتری قبل از اجازه دادن به قطع دریافت، از جهت اشتباه محافظت می‌کرد.

> [!danger]
> کانال وب‌پوش امروز علاوه بر اشتراک، به یک ردیف دستگاه هم نیاز دارد. توزیع‌کننده برای کانال‌های `push` و `webpush` سراغ رجیستری دستگاه می‌رود و اگر چیزی پیدا نکند، قبل از اینکه فرستنده وب‌پوش اصلا صدا زده شود، پیام را با دلیل `not_reachable` کنار می‌گذارد. اشتراک‌های مرورگر در جدول جداگانه‌ای (`webpush_subscriptions`) ذخیره می‌شوند، پس بازدیدکننده‌ای که فقط `POST /v1/webpush/subscribe` را صدا زده امروز غیرقابل دسترس شمرده می‌شود. این بررسی شرط ندارد: روی نصبی که اصلا انبار دستگاه وصل نشده هم اجرا می‌شود، و آنجا هر پوش و هر وب‌پوش، بدون استثنا، همین‌جا کنار گذاشته می‌شود.

راه‌حل امروزی این است که همان مرورگر را به‌عنوان دستگاه هم ثبت کنید، با `platform: "web"` و `endpoint` به‌عنوان توکن مسیر `webpush`:

```bash title="همان مرورگر، به‌عنوان یک ردیف دستگاه"
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "browser-9f31",
    "user_id": "u_9137",
    "platform": "web",
    "tokens": { "webpush": "https://fcm.googleapis.com/fcm/send/abc123" }
  }'
```

```json
{"status":"ok"}
```

این را با چشم باز بنویسید: هیچ تستی وجود ندارد که کانال وب‌پوش را از مسیر توزیع‌کننده بپوشاند. فرستنده وب‌پوش مستقیم تست شده، ولی مسیر کامل کمپین نه.

بقیه سطح وب، شامل service worker و اینکه چرا باید از ریشه دامنه سرو شود و کلید VAPID را از کجا می‌گیرید، در [SDK وب](/docs/sdk-web) است.

## پیام‌رسان‌ها: بله، ایتا، روبیکا {#messengers}

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

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

```bash title="وصل کردن یک شناسه چت"
curl -X POST https://in.segmentic.net/v1/messenger/link \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "platform": "bale",
    "chat_id": "44120099",
    "source": "bot_start"
  }'
```

```json
{"status":"ok"}
```

| فیلد | نوع | اجباری | پیش‌فرض |
|---|---|---|---|
| `user_id` | رشته | بله | |
| `platform` | رشته | بله، یکی از `bale`، `eitaa`، `rubika` | |
| `chat_id` | رشته | بله | |
| `username` | رشته | نه | ذخیره می‌شود و هرگز با ثبت بعدی پاک نمی‌شود |
| `source` | رشته | نه | `bot_start` |

`source` ثبت می‌کند شناسه از کجا آمده. پیش‌فرضش `bot_start` است، تنها مسیری که رضایت واقعی دارد؛ هر چیز دیگر ارزش دارد که بعدا بشود پیدایش کرد. کسی که در بات `/start` زده داستان رضایتش با کسی که شناسه‌اش از یک فایل CSV آمده یکی نیست.

قطع اتصال فقط `user_id` و `platform` می‌خواهد:

```bash title="قطع اتصال"
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id": "u_9137", "platform": "eitaa"}'
```

```json
{"status":"ok"}
```

ردها: `400` با `{"status":"error","message":"user_id, chat_id and a known platform are required"}` روی اتصال، و `{"status":"error","message":"user_id and a known platform are required"}` روی قطع اتصال. پلتفرمی مثل `telegram` همینجا رد می‌شود؛ ستون یک `CHECK` دارد و مقدار ناشناخته در پستگرس با خطایی می‌شکست که هیچ‌کس نمی‌تواند رویش کاری بکند.

وصل کردن دوباره تنها راه پاک‌کردن یک بلاک است: `blocked_at` و `blocked_reason` صفر می‌شوند و هیچ‌چیز دیگری آن‌ها را پاک نمی‌کند. تلاش خودکاری که تصمیم بگیرد بلاک منقضی شده، پیام دادن به کسی است که رفته.

> [!warn]
> هیچ وب‌هوکی برای پیام‌های بات وجود ندارد. مسیر ورودی وب‌هوک فقط منابع یکپارچه‌سازی (دیجی‌کالا، باسلام، ترب، زرین‌پال، ووکامرس، شاپیفای، سگمنت) را می‌پذیرد و هیچ‌کدام پیام‌رسان نیستند. یعنی بات را خودتان اجرا می‌کنید: بات شما `/start` می‌گیرد، بک‌اند شما شناسه چت را به شناسه کاربر خودتان نگاشت می‌کند، و بک‌اند شما `POST /v1/messenger/link` را صدا می‌زند.

## وقتی کمپین پوش می‌فرستد، دقیقا چه می‌شود {#send-time}

ترتیب مراحل خودش یک تصمیم است.

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

**رجیستری فقط برای کانال‌هایی خوانده می‌شود که یک نصب را آدرس می‌دهند**، یعنی `push` و `webpush`. پیامک و ایمیل گیرنده‌شان را از روی پرونده پیدا می‌کنند، پس نداشتن دستگاه برای آن‌ها بی‌معنی است.

کوئری تحویل این است:

```sql
SELECT d.user_id, d.device_id, d.platform, d.has_gms,
       d.app_version, d.locale, d.timezone, d.last_seen_at,
       t.transport, t.token
FROM devices d
JOIN device_tokens t
  ON t.tenant_id = d.tenant_id AND t.device_id = d.device_id AND t.retired_at IS NULL
WHERE d.tenant_id = $1
  AND d.user_id = ANY($2)
  AND d.revoked_at IS NULL
  AND d.push_enabled
  AND d.last_seen_at >= $3
ORDER BY d.user_id, d.last_seen_at DESC, d.device_id
```

پس یک دستگاه برای کمپین نامرئی است اگر: توکن زنده نداشته باشد، باطل شده باشد، `push_enabled` آن `false` باشد، بیش از ۱۸۰ روز دیده نشده باشد، بعد از پنجمین نصب تازه بیاید، یا اصلا `user_id` نداشته باشد.

**نداشتن هیچ دستگاهی شکست نیست.** نتیجه `suppressed` با دلیل `not_reachable` است. آن آدم وجود دارد و راضی است؛ ما فقط راهی برای رسیدن به او نداریم، و گفتن همین است که گزارش دسترسی را قابل عمل می‌کند نه مرموز.

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

برای هر دستگاه، روتر مسیر را انتخاب می‌کند. ترتیب اولویت:

| پلتفرم | ترتیب امتحان |
|---|---|
| `android` | `fcm`، بعد `bazaar`، بعد `myket`، بعد `huawei`، بعد `mqtt` |
| `ios` | `apns`، بعد `mqtt` |
| `web` | `webpush` |

FCM اول است چون وقتی پلی سرویسز کار می‌کند سریع‌ترین و ارزان‌ترین مسیر است. بازار و مایکت بعدش، چون دقیقا همان دستگاه‌هایی را می‌پوشانند که FCM نمی‌تواند. `mqtt` آخر است و امروز هیچ ارائه‌دهنده‌ای ندارد، پس همیشه کنار می‌رود.

این فهرست با سه چیز فیلتر می‌شود: دستگاه واقعا برای آن مسیر توکن داشته باشد، ارائه‌دهنده‌ای برایش پیکربندی شده باشد، و برای FCM، دستگاه `has_gms: false` گزارش نکرده باشد. صفر مسیر یعنی نتیجه `rejected` با متن `push: no usable transport for this device`.

جواب هر مسیر سرنوشت بعدی را تعیین می‌کند:

| نتیجه ارائه‌دهنده | چه می‌شود |
|---|---|
| `sent` | برمی‌گردد، موفقیت روی مدار ثبت می‌شود |
| `invalid_token` | مسیر بعدی امتحان می‌شود، و آن توکن بازنشسته می‌شود |
| `unavailable` یا `rate_limited` | مسیر بعدی امتحان می‌شود، شکست روی مدار شمرده می‌شود |
| `rejected` | همانجا برمی‌گردد. بدنه ردشده همه‌جا رد می‌شود و امتحان مسیر دیگر فقط سهمیه می‌سوزاند |

توکن مرده روی یک مسیر هیچ چیزی درباره مسیرهای دیگر نمی‌گوید، و همین رد شدن به مسیر بعدی است که مخاطبی را که FCM نمی‌تواند برساند بازمی‌گرداند.

**توکن مرده بازنشسته می‌شود و آخرینش نصب را باطل می‌کند.** وقتی ارائه‌دهنده بگوید توکن نامعتبر است، آن توکن `retired_at` می‌خورد. اگر آن دستگاه دیگر هیچ توکن زنده‌ای نداشته باشد، خود دستگاه با `revoked_reason = 'uninstall_detected'` باطل می‌شود. این تنها سیگنال واقع‌بینانه حذف اپ است: اپ نمی‌تواند در حین حذف شدن unregister را صدا بزند، پس بدون این، پایگاه نصب هر مشتری فقط رشد می‌کرد. بازنشسته می‌شود نه حذف، تا اگر همان توکن با ثبت بعدی برگشت، نصب دوباره تشخیص داده شود.

**مدار محافظ.** ۱۰ شکست پشت سر هم یک مسیر را به مدت ۳۰ ثانیه باز می‌کند. اگر همه مسیرها به‌عنوان در دسترس نبودن کنار رفتند، نتیجه `unavailable` با متن `every transport was unavailable` است.

هر پوش شناسه‌های انتساب را با خودش می‌برد، و جای آن‌ها در هر مسیر فرق می‌کند:

| مسیر | شناسه‌ها کجا سوارند |
|---|---|
| `fcm` | داخل `message.data`: `sg_mid`، `sg_cid`، `sg_jid`، `sg_link`، `sg_t` |
| `bazaar` و `myket` و `huawei` | داخل `data`: `sg_mid`، `sg_link`، `sg_t`. بدون شناسه کمپین و سناریو. هواوی این نگاشت را به‌صورت رشته JSON می‌گیرد نه شیء |
| `apns` | سطح بالای بدنه، کنار `aps`: `sg_mid`، `sg_link`، `sg_t` |
| `webpush` | داخل JSON رمزشده: `mid`، `tkn`، `url` |

شناسه‌ها روی خود لینک هم سوار می‌شوند، پس رهگیری کلیک پوش به هیچ سرویس ریدایرکتی از طرف ما نیاز ندارد: آدم روی سایت خود مشتری فرود می‌آید که SDK آنجا از قبل حاضر است. پارامترهای UTM خود مشتری هم روی همان لینک می‌روند.

## خواندن دستگاه‌های یک پرونده {#reading-back}

این کارت پرونده در پنل است، نه یک اندپوینت که کلید API شما به آن برسد. مسیرش `GET /v1/profiles/{user_id}/devices` است و فقط روی کنترل‌پلین داشبورد ثبت شده؛ آن لیسنر عمدا از بیرون شبکه داخلی قابل دسترس نیست. همان مسیر روی `https://api.segmentic.net` به هندلر پیش‌فرض می‌افتد و `404 unknown_endpoint` می‌گیرد.

پنل خودش این را با کوکی نشست شما و از راه پراکسی داخلی‌اش صدا می‌زند:

```http
GET /api/proxy/v1/profiles/u_9137/devices
```

```json
{
  "devices": [
    {
      "platform": "android",
      "transports": "bazaar,fcm",
      "app_version": "3.4.0",
      "model": "Xiaomi Redmi Note 12",
      "timezone": "Asia/Tehran",
      "push_enabled": true,
      "revoked": false,
      "last_seen": "2026-08-05T09:14:22Z"
    }
  ]
}
```

مجوز لازم `profile.read` است. کسی که اجازه دارد ویژگی‌ها و تاریخچه رویداد یک آدم را ببیند، از قبل نیمه حساس‌تر ماجرا را می‌خواند.

| فیلد | نوع | توضیح |
|---|---|---|
| `platform` | رشته | |
| `transports` | رشته | با ویرگول جدا شده. یک نصب با دو توکن یک گوشی است نه دو تا. توکن‌های بازنشسته هم در همین رشته می‌آیند، پس مسیری که اینجا می‌بینید لزوما زنده نیست |
| `app_version` | رشته | وقتی خالی باشد نمی‌آید |
| `model` | رشته | وقتی خالی باشد نمی‌آید |
| `timezone` | رشته | وقتی خالی باشد نمی‌آید |
| `push_enabled` | بولین | |
| `revoked` | بولین | |
| `last_seen` | رشته | `RFC3339` به وقت UTC |

ترتیب `last_seen_at DESC` است و سقف `50` ردیف. نصب‌های باطل‌شده عمدا در فهرست هستند: «شما اپ را سوم ماه حذف کردید» جواب سوال «چرا پوش نمی‌گیرم؟» است و پنهان کردن ردیف، سوال را بی‌جواب می‌گذارد.

خطاها: `400` با `user_id is required`، و `503` وقتی کوئری شکست بخورد.

درخواست حق فراموش‌شدن اول `device_tokens` و بعد ردیف `devices` را حذف می‌کند، چون توکن‌ها به `device_id` آویزان‌اند و در غیر این صورت تا ابد به آن‌ها پوش می‌رفت. جزئیات در [حریم خصوصی](/docs/privacy).

## چیزهایی که وجود ندارند {#absent}

اینجا صریح می‌نویسیم، چون یک شکاف صادقانه ارزانتر از یک جمله محتمل است که یک بعدازظهر شما را می‌گیرد.

- هیچ مسیر دستگاهی روی هاست مدیریت (`sk_seg_...`)، حتی خواندن. `GET /v1/profiles/{user_id}/devices` فقط روی کنترل‌پلین داشبورد ثبت شده و عمومی مسیریابی نمی‌شود، پس با کلید API نمی‌شود فهرست دستگاه‌های یک نفر را گرفت.
- هیچ ابزار MCP مربوط به دستگاه یا پوش.
- ارائه‌دهنده MQTT. ثابتش هست، ثبت می‌شود، ارسال نمی‌شود.
- اندپوینت ثبت دسته‌ای دستگاه. یک درخواست، یک دستگاه.
- هیچ شکل `GET` یا `DELETE` از این اندپوینت‌ها. همه `POST` با بدنه JSON‌اند.
- ترتیب مسیر پیش‌فرض برای `windows`، `macos` و `linux`. ثبت می‌شوند، تحویل نمی‌گیرند.
- هر جستجوی دستگاه بر اساس `anonymous_id`. نصب ناشناس ذخیره می‌شود و هدف هیچ کمپینی نمی‌شود.
- وب‌هوک پیام بات که شناسه چت بله، ایتا یا روبیکا را خودکار وصل کند.
- هر اعتبارسنجی روی شکل توکن FCM، بازار یا مایکت، فراتر از «خالی نباشد» و «از `4096` بایت بلندتر نباشد».
- هدر `apns-topic` روی درخواست‌های APNs، و با آن `apns-push-type`، `apns-expiration`، `apns-priority` و `apns-collapse-id`. اپل برای احراز هویت مبتنی بر توکن `apns-topic` را لازم دارد، پس این یک شکاف واقعی است نه ظرافت مستندات. عملا یعنی `ttl`، `collapse_key` و `priority` روی FCM اعمال می‌شوند و روی APNs نادیده گرفته می‌شوند.
- اعتبارنامه APNs در سطح مشتری. کاتالوگ کانال‌ها برای `push` فقط `fcm`، `bazaar`، `myket` و `huawei` دارد، پس پوش iOS برای همه مشتری‌های یک نصب روی کلید APNs خود آن نصب می‌رود.
- ارسال تاییدشده بازار یا مایکت با توکن واقعی فروشگاه. نگاشت توکن پذیرفته می‌شود ولی هرگز با توکن واقعی امتحان نشده، چون هر دو به حساب توسعه‌دهنده و اپ منتشرشده نیاز دارند.
- تست سطح توزیع‌کننده برای کانال وب‌پوش.

---

# ارسال رویداد از سرور به سرور

> برای چیزی که فقط بک‌اند شما از آن مطمئن است: پرداخت موفق، ارسال سفارش، لغو اشتراک.
> https://segmentic.net/docs/server

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

## دو در، و یکی نیستند {#two-doors}

| | کلکتور | API مدیریت |
|---|---|---|
| آدرس | `https://in.segmentic.net/v1/batch` | `https://api.segmentic.net/v1/events` |
| کلید | کلید نوشتن، `wk_seg_...` | کلید API، `sk_seg_...` |
| دسترسی لازم | ندارد. خود کلید نوشتن مجوز است | `profile.write` |
| نام آرایه در بدنه | `batch` | `events` |
| پاسخ موفق | `200` | `202` |
| حذف تکراری با `message_id` | دارد | ندارد |
| برگرداندن هشدارها | دارد | ندارد، دور ریخته می‌شوند |
| سقف عقب‌بردن زمان | نگهداشت رویداد همان حساب | ثابت، ۳۰ روز |
| `context` و `sent_at` در سطح بسته | دارد | چنین فیلدهایی ندارد |
| بیشترین تعداد آیتم | ۵۰۰ | ۵۰۰ |
| بیشترین حجم بدنه | ۵ مگابایت | ۸ مگابایت |
| مصرف از بودجهٔ درخواست | ندارد | دارد، ۵ واحد از ۶۰۰ در دقیقه |

کلید نوشتن از روی عمد عمومی است. داخل جاوااسکریپت خود مشتری و داخل اپ اندروید او منتشر می‌شود و کل پلتفرم روی این فرض ساخته شده که هر کسی می‌تواند آن را بخواند. یک کلید `wk_` در باندل عمومی یعنی همه‌چیز طبق طراحی کار می‌کند؛ یک کلید `sk_` در همان جا یعنی حادثهٔ امنیتی. پس استفاده از کلید نوشتن در بک‌اند شما افت امنیتی نیست: همان کلید است که همان کار را می‌کند، فقط از یک ماشین به‌جای یک گوشی.

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

```json
{
  "error": {
    "code": "write_key_rejected",
    "message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
  }
}
```

## بک‌اند شما کدام را باید بردارد {#which-one}

`POST /v1/batch` کلکتور را.

دلیلش حذف تکراری است. خط لولهٔ سفارش شما دوباره خواهد فرستاد. یک تایم‌اوت روی لودبالانسر، یک ریدیپلوی وسط درخواست، یک ورکر که بعد از POST و قبل از علامت‌زدن ردیف کرش می‌کند: هر کدام به یک جا می‌رسند، به فرستادن دوبارهٔ همان بسته. کلکتور `message_id` را با `SET NX` در ردیس و پنجرهٔ ۴۸ ساعته ثبت می‌کند، پس تحویل دوم با `200` جواب می‌گیرد و هرگز به انبار داده نمی‌رسد. `POST /v1/events` این کار را نمی‌کند. پاکت‌ها را مستقیم به صف می‌دهد. یک بستهٔ صد سفارشی که دوباره فرستاده شود می‌شود دویست سفارش، و اولین نشانه‌اش یک عدد درآمد است که هیچ‌کس نمی‌تواند با حسابداری تطبیقش بدهد.

دلیل دوم، پنجرهٔ عقب‌بردن زمان است. کلکتور نگهداشت رویداد همان حساب را می‌خواند و اجازه می‌دهد تا همان‌قدر عقب بروید. `POST /v1/events` این گزینه را پر نمی‌کند، پس همان پیش‌فرض ثابت ۳۰ روزه اعمال می‌شود و هرچه قدیمی‌تر باشد، بی‌صدا درست به ۳۰ روز قبل منتقل می‌شود. در بخش [زمان رویداد](/docs/server#timestamps) توضیح داده شده.

سراغ `POST /v1/events` بروید وقتی سرویس شما همین حالا یک کلید API دارد و اضافه‌کردن یک کلید دوم به استقرارتان دردسر بزرگ‌تری است، یا وقتی فراخوان‌کننده یک ایجنت است که همین حالا برای سگمنت و گزارش با `api.segmentic.net` حرف می‌زند. در این حالت بپذیرید که مسئولیت دوباره‌نفرستادن یک رویداد با شماست.

> [!note]
> هر دو در به یک خط لوله و یک جدول در انبار داده می‌رسند. هیچ چیزی در پایین‌دست نمی‌تواند بگوید رویداد از کدام در آمده، جز اینکه رویدادهای `POST /v1/events` مقدار `app_id` برابر `0` دارند و هیچ آی‌پی و User-Agent همراهشان نیست.

## کلکتور: POST /v1/batch {#batch}

کلید در `Authorization: Bearer` می‌آید. دو شکل دیگر هم پذیرفته می‌شود، چون یک بیکن مرورگری نمی‌تواند هدر بگذارد: `X-Segmentic-Key: wk_seg_...` و `?write_key=wk_seg_...`. از سمت سرور همان هدر را بگذارید. فقط رشتهٔ دقیق `Bearer ` (با B بزرگ و یک فاصله) از `Authorization` جدا می‌شود؛ هر طرح دیگری از این بررسی رد می‌شود، می‌رود سراغ دو شکل بعدی، و در نهایت با «کلید نیست» شکست می‌خورد.

هر آیتم `type` خودش را دارد. روی آدرس‌های تک‌رویدادی (`/v1/track` و `/v1/identify` و بقیه) مسیر تعیین‌کنندهٔ نوع است و `type` داخل بدنه نادیده گرفته می‌شود، ولی روی `/v1/batch` مسیری برای خواندن نوع وجود ندارد، پس آیتمی که `type` نداشته باشد یا مقدار ناشناخته بفرستد رد می‌شود. پنج مقدار مجاز: `track`، `identify`، `alias`، `page`، `screen`.

```bash title="یک بسته با دو رویداد"
curl -sS https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "context": { "locale": "fa-IR" },
    "batch": [
      {
        "type": "track",
        "message_id": "order-8821-completed",
        "event": "order_completed",
        "user_id": "u_44120",
        "timestamp": "2026-08-07T09:12:41Z",
        "properties": {
          "order_id": "8821",
          "revenue": 4800000,
          "currency": "IRR",
          "city": "تهران"
        }
      },
      {
        "type": "identify",
        "message_id": "profile-44120-v7",
        "user_id": "u_44120",
        "traits": { "phone": "09123456789", "city": "تهران" }
      }
    ]
  }'
```

```json title="200 OK"
{ "status": "ok", "accepted": 2 }
```

`context` و `sent_at` سطح بسته روی هر آیتمی که مقدار خودش را نداشته باشد کپی می‌شوند، و هرگز روی مقداری که خودش داشته نوشته نمی‌شوند. این برای آن ساخته شده که یک SDK موبایل مدل دستگاه را یک بار برای پنجاه رویداد بفرستد، و از سمت سرور هم برای یک `context.campaign` مشترک به همان اندازه به‌درد می‌خورد.

فیلدهای هر آیتم:

| فیلد | نوع | اجباری | توضیح |
|---|---|---|---|
| `type` | رشته | روی batch بله | یکی از `track`، `identify`، `alias`، `page`، `screen` |
| `message_id` | رشته | نه، ولی بفرستید | حداکثر ۲۵۶ بایت. اگر نباشد ساخته می‌شود و هشدار می‌گیرید |
| `event` | رشته | وقتی `type` برابر `track` است، بله | حداکثر ۱۲۸ بایت بعد از نرمال‌سازی |
| `user_id` | رشته | یکی از `user_id` یا `anonymous_id` | حداکثر ۲۵۶ بایت |
| `anonymous_id` | رشته | یکی از `user_id` یا `anonymous_id` | حداکثر ۲۵۶ بایت. قاعدهٔ قالب ندارد، لازم نیست UUID باشد |
| `previous_id` | رشته | وقتی `type` برابر `alias` است، بله | شناسه‌ای که از آن ادغام می‌شود. برخلاف بقیهٔ شناسه‌ها هیچ سقف طولی ندارد، فقط همان سقف بدنه |
| `timestamp` | `RFC 3339` | نه | پیش‌فرض، زمان دریافت روی سرور |
| `sent_at` | `RFC 3339` | نه | تصحیح اختلاف ساعت را روشن می‌کند. قبل از پرکردنش پایین‌تر را بخوانید |
| `properties` | شیء | نه | حداکثر ۲۵۶ کلید، هر کلید ۱۲۸ بایت، هر مقدار رشته‌ای ۸۱۹۲ بایت |
| `traits` | شیء | نه | حداکثر ۲۵۶ کلید، با همان حدود |
| `context` | شیء | نه | ساختارش در [رویدادها](/docs/events) آمده |

`timestamp` و `sent_at` را کتابخانهٔ JSON زبان Go به زمان تبدیل می‌کند و آن فقط `RFC 3339` را می‌پذیرد. ثانیهٔ یونیکس، میلی‌ثانیهٔ یونیکس و تاریخ خالی `2026-08-07` هیچ‌کدام تبدیل نمی‌شوند و کل درخواست با `400 malformed JSON` رد می‌شود، نه فقط همان یک آیتم.

درآمد از داخل properties خوانده می‌شود، به این ترتیب: اولین مقدار ناصفر از `revenue`، `total`، `value`؛ اگر هیچ‌کدام نبود، `price` ضرب در `quantity` که در نبودش ۱ فرض می‌شود. `currency` پیش‌فرض `IRR` است و بزرگ‌حرف می‌شود، پس `"irt"` به شکل `IRT` ذخیره می‌شود. هیچ تبدیل نرخی انجام نمی‌شود.

هر وضعیتی که این آدرس برمی‌گرداند:

| موقعیت | کد | بدنه |
|---|---|---|
| پذیرفته شد، کامل یا بخشی | `200` | `{"status":"ok","accepted":N,...}` |
| کلیدی روی درخواست نبود | `401` | `{"status":"error","message":"missing write key"}` |
| کلید ناشناس، باطل‌شده، یا حساب معلق | `401` | `{"status":"error","message":"invalid write key"}` |
| جست‌وجوی کلید سمت ما شکست خورد | `503` با `Retry-After: 5` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |
| بدنهٔ بزرگ‌تر از ۵ مگابایت | `413` | `{"status":"error","message":"request body too large"}` |
| بدنه JSON نیست | `400` | `{"status":"error","message":"malformed JSON"}` |
| `batch` خالی است | `400` | `{"status":"error","message":"batch_empty"}` |
| بیشتر از ۵۰۰ آیتم | `400` | `{"status":"error","message":"batch_too_large: 501 items, limit 500"}` |
| حساب از سقف ماهانه گذشته | `402` | `{"status":"error","message":"<جملهٔ فارسی>"}` |
| هم باس و هم بافر دیسک شکست خوردند | `503`، بدون `Retry-After` | `{"status":"error","message":"temporarily unavailable, please retry"}` |

کلید ناشناس، باطل‌شده و حساب معلق از روی عمد به یک `401` واحد تبدیل می‌شوند تا نشود از این آدرس برای فهمیدن اینکه چه کلیدهایی وجود دارند استفاده کرد.

پیام `402` همیشه فارسی است. کلکتور هیچ میان‌افزار زبانی ندارد، پس `Accept-Language: en` هیچ اثری روی آن نمی‌گذارد. روی کد وضعیت شرط بگذارید، نه روی متن.

## API مدیریت: POST /v1/events {#events}

```bash title="دو رویداد از راه API مدیریت"
curl -sS https://api.segmentic.net/v1/events \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "type": "track",
        "message_id": "order-8821-completed",
        "event": "order_completed",
        "user_id": "u_44120",
        "timestamp": "2026-08-07T09:12:41Z",
        "properties": { "order_id": "8821", "revenue": 4800000, "currency": "IRR" }
      },
      {
        "type": "track",
        "event": "order_completed",
        "properties": { "order_id": "8822" }
      }
    ]
  }'
```

```json title="202 Accepted"
{
  "accepted": 1,
  "rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
```

`202` و نه `200`، چون رویدادها در صف نشسته‌اند نه ذخیره‌شده. چند ثانیه بعد قابل کوئری می‌شوند. اگر `200` می‌گفتیم، شما را تشویق می‌کرد بلافاصله بخوانیدشان و نتیجه بگیرید که گم شده‌اند.

شکل هر آیتم همان پاکتی است که کلکتور می‌گیرد. چیزی که فرق دارد پوشش بیرونی است: نام آرایه `events` است، و فیلدهای `context` و `sent_at` در سطح بسته وجود ندارند. هرچه می‌خواستید آنجا بگذارید، باید روی تک‌تک آیتم‌ها تکرار شود.

این مسیر فقط وقتی ثبت می‌شود که استقرار یک importer پیکربندی‌شده داشته باشد. وقتی ندارد، مسیر به هندلر پیش‌فرض می‌افتد و `404 unknown_endpoint` می‌گیرد. `GET /v1/capabilities` وضعیتش را زیر `features.ingest` گزارش می‌کند.

دسترسی لازم `profile.write` است که نقش‌های `owner` و `admin` و `marketer` دارند و `analyst` ندارد. متن رد، خود دسترسی موردنیاز را نام می‌برد تا مجبور نشوید برای فهمیدنش تیکت پشتیبانی باز کنید:

```json
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry profile.write, see GET /v1/whoami for what it does carry",
    "need": "profile.write"
  }
}
```

هر وضعیتی که این آدرس برمی‌گرداند:

| موقعیت | کد | `error.code` |
|---|---|---|
| پذیرفته شد، کامل یا بخشی | `202` | ندارد |
| کلیدی نبود، یا کلید شناخته نشد | `401` | `unauthenticated` |
| کلید نوشتن `wk_` فرستاده شده | `401` | `write_key_rejected` |
| تاریخ انقضای کلید گذشته | `401` | `key_expired` |
| کلید `profile.write` ندارد | `403` | `forbidden` |
| `events` خالی است | `400` | `batch_empty` |
| بدنه JSON نیست | `400` | `malformed_json` |
| بیشتر از ۵۰۰ رویداد | `413` | `batch_too_large`، با `details` برابر `{"limit":500,"sent":N}` |
| همهٔ رویدادها رد شدند | `422` | `all_events_rejected`، با آرایهٔ آیتم‌ها در `details` |
| حساب از سقف ماهانه گذشته | `402` | `quota_cancelled`، `quota_trial_over`، `quota_event_cap` یا `quota_message_cap` |
| بودجهٔ درخواست این کلید تمام شده | `429` با `Retry-After: 60` | `budget_exhausted` |
| سرویس بودجه خطا داد | `503` | `budget_unavailable` |
| صف در دسترس نبود | `503` | `ingest_unavailable` |

> [!warn]
> روی این API فیلد `error` همیشه یک شیء نیست. ده مسیر از مسیرهای این API هندلر داشبورد را دوباره استفاده می‌کنند و به‌جایش `{"error":"یک رشته"}` برمی‌گردانند: دو مسیر `GET /v1/schema/*`، `POST /v1/audiences/count`، فهرست و خواندن سگمنت، فهرست و خواندن کمپین، دو مسیر `POST /v1/reports/*`، و `POST /v1/messages`. خود `POST /v1/events` همیشه شکل شیء را می‌دهد، ولی کلاینتی که یک پارسر خطا را در کل API به اشتراک می‌گذارد باید قبل از خواندن `error.code` نوع `error` را چک کند.

## بسته‌بندی و سقف آن {#size}

۵۰۰ آیتم در هر درخواست، روی هر دو در. حد سختی است: ۵۰۱ آیتم کامل رد می‌شود و هیچ‌چیز از آن بسته ذخیره نمی‌شود. متن رد روی API مدیریت خود سقف را داخل `details` منتشر می‌کند تا کلاینتی که دارد اندازهٔ حلقه‌اش را تنظیم می‌کند، مجبور نباشد عدد را با آزمون و خطا پیدا کند.

سقف حجم بدنه جداست و اول با رویدادهای چاق پر می‌شود نه با تعداد زیاد. ۵ مگابایت روی کلکتور، ۸ مگابایت روی API مدیریت. ۵۰۰ رویداد که هر کدام ۲۵۶ ویژگی داشته باشند خیلی قبل از رسیدن به ۵۰۰ آیتم، از ۵ مگابایت رد می‌شوند.

فشرده‌سازی وجود ندارد. هیچ‌کدام از این دو آدرس `Content-Encoding` را نمی‌خوانند، پس یک بدنهٔ gzip شده به‌صورت بایت‌هایی می‌رسد که JSON نیستند و با `400` رد می‌شود. اگر رویدادهایتان بزرگ‌اند، درخواست بیشتری بفرستید نه درخواست بزرگ‌تر.

`Content-Type` هم اجبار نمی‌شود. بدنه هرچه اعلام کنید به‌عنوان JSON خوانده و تجزیه می‌شود. با این حال `application/json` بفرستید تا پراکسی وسط راه تصمیم دیگری نگیرد.

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

## شکست بخشی، و خواندن خطای هر آیتم {#partial-failure}

یک آیتم خراب کل بسته را غرق نمی‌کند. هر دو آدرس همهٔ آیتم‌ها را اعتبارسنجی می‌کنند، خوب‌ها را نگه می‌دارند، و بدها را با شمارهٔ خانه‌شان در آرایه‌ای که فرستادید نام می‌برند.

کلکتور شمارش‌ها را به‌همراه آرایهٔ `errors` می‌دهد:

```json title="200 OK، یک آیتم رد شد"
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [ { "index": 1, "reason": "missing_identity" } ]
}
```

API مدیریت خود آرایه را زیر `rejected` می‌دهد:

```json title="202 Accepted، یک آیتم رد شد"
{
  "accepted": 2,
  "rejected": [ { "index": 1, "reason": "missing_identity" } ]
}
```

یک کلمه، دو معنی. روی کلکتور `rejected` یک عدد است و جزئیات در `errors` است؛ روی API مدیریت `rejected` خود جزئیات است. پارسری که برای یکی نوشته شده، دیگری را «صفر خطا» می‌خواند.

`index` شمارهٔ خانه در آرایه‌ای است که شما فرستادید، نه در زیرمجموعهٔ پذیرفته‌شده. این عمدی است: رویدادهایی که رد می‌شوند ممکن است هنوز هیچ شناسه‌ای نداشته باشند، که خودش نصف دلیل رد شدنشان است، پس شمارهٔ خانه تنها راه پیداکردن دوبارهٔ آن‌هاست.

`reason` با یک کد پایدار شروع می‌شود. فهرست کامل:

| کد | معنی |
|---|---|
| `unknown_type` | `type` نبود یا جزو آن پنج مقدار نیست |
| `missing_identity` | نه `user_id` بود و نه `anonymous_id` |
| `missing_event_name` | `type` برابر `track` است و `event` خالی بود |
| `event_name_too_long` | بعد از نرمال‌سازی از ۱۲۸ بایت گذشت |
| `event_name_invalid_chars` | نام رویداد کاراکتر کنترلی دارد |
| `id_too_long` | `user_id` یا `anonymous_id` یا `message_id` از ۲۵۶ بایت گذشت |
| `missing_previous_id` | `type` برابر `alias` است و `previous_id` خالی بود |

یکی از این‌ها با مقدار خطاساز به دنبالش می‌آید، `unknown_type`، چون دانستن کد بدون دانستن مقدار شما را برمی‌گرداند سراغ لاگ‌های خودتان. آیتمی با `"type": "trak"` این متن را تولید می‌کند: `unknown_type: "trak"`، نه `unknown_type` خالی. شش کد دیگر همیشه تنها می‌آیند و هیچ‌کدام نمی‌گویند کدام فیلد یا کدام مقدار خطا داشته.

> [!danger]
> `reason` را با پیشوند تطبیق بدهید، یا روی اولین `": "` بشکنیدش. مقایسهٔ برابری با `"unknown_type"` روی `unknown_type: "trak"` عمل نمی‌کند، و این درست همان حالتی است که هشدارتان را برایش نوشته بودید.

وقتی همهٔ آیتم‌ها رد می‌شوند، دو در دوباره از هم جدا می‌شوند. کلکتور `200` می‌دهد و `accepted` در بدنه نیست، چون این فیلد وقتی صفر باشد حذف می‌شود. API مدیریت `422 all_events_rejected` می‌دهد و کل آرایه را در `error.details` می‌گذارد، با این استدلال که مدیریت خطای یک سرویس روی کد وضعیت شاخه می‌زند و بسته‌ای که همه‌اش رد شده یک باگ سمت فراخوان‌کننده است که باید دیده شود.

هشدار با خطا فرق دارد: آیتم پذیرفته شد و چیزی در آن عوض شد. فقط کلکتور هشدار برمی‌گرداند، و حدود ۵۰ تا در هر پاسخ. `POST /v1/events` هشدارها را حساب می‌کند و دور می‌ریزد، پس `message_id` جاافتاده آنجا بی‌صدا ساخته می‌شود و هرگز به شما گفته نمی‌شود.

| کد هشدار | چه اتفاقی افتاد |
|---|---|
| `generated_message_id` | `message_id` فرستاده نشد؛ ارسال دوبارهٔ این رویداد قابل حذف‌شدن نیست |
| `timestamp_in_future` | بیش از یک ساعت جلوتر از سرور؛ به زمان دریافت چسبانده شد |
| `timestamp_too_old` | قدیمی‌تر از پنجرهٔ ورود؛ به لبهٔ پنجره چسبانده شد |
| `too_many_properties` | بیش از ۲۵۶ ویژگی؛ اضافه‌ها حذف شدند |
| `too_many_traits` | بیش از ۲۵۶ ویژگی پرونده؛ اضافه‌ها حذف شدند |
| `unserialisable_property` | یک ویژگی قابل کدگذاری نبود و حذف شد. `field` نامش را می‌گوید |
| `invalid_phone` | ویژگی پروندهٔ `phone` شمارهٔ موبایل ایرانی معتبری نبود؛ همان‌طور که فرستادید ذخیره شد و `phone_operator` برایش درنیامد |
| `invalid_national_id` | ویژگی پروندهٔ `national_id` از بررسی رقم کنترلی رد نشد و اصلا ذخیره نشد |

## message_id، و چرا سرور باید خودش بگذاردش {#idempotency}

`message_id` تنها چیزی است که ارسال دوباره را امن می‌کند.

کلکتور حذف تکراری را روی جفت حساب شما و `message_id` انجام می‌دهد، با `SET NX` در ردیس و پنجرهٔ ۴۸ ساعته. تحویل اول منتشر می‌شود. هر تکرار داخل آن پنجره با `200 {"status":"ok","accepted":1}` جواب می‌گیرد، درست مثل بار اول، و هیچ چیز تازه‌ای به انبار داده نمی‌رسد. حساب هم نمی‌شود: یک کلاینت که دوباره می‌فرستد برای ما یک جست‌وجوی ردیس خرج دارد، نه یک سطر در فاکتور. تکراری داخل یک بسته هم گرفته می‌شود، پس بسته‌ای که به اشتباه یک سفارش را دو بار آورده، یک بار ذخیره می‌شود.

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

اگر `message_id` نفرستید، یکی برایتان ساخته می‌شود و هشدار `generated_message_id` می‌گیرید. آن هشدار تزئینی نیست. یعنی این رویداد هیچ محافظتی در برابر ارسال دوباره ندارد، و عددی که تغذیه می‌کند هر بار که شبکه‌تان یک بعدازظهر بد داشته باشد، بالاتر می‌رود.

شناسه را از چیزی بسازید که دیتابیس خودتان یکتا بودنش را تضمین کرده، و قطعی بسازیدش تا ارسال دوباره همان رشته را حساب کند که بار اول:

| رویداد | یک `message_id` خوب |
|---|---|
| سفارش پرداخت شد | `order-8821-completed` |
| سفارش مرجوع شد | `order-8821-refunded` |
| وضعیت مرسوله عوض شد | `shipment-4471-delivered` |
| همگام‌سازی شبانهٔ پرونده | `profile-44120-2026-08-07` |

هرگز از UUID تصادفی‌ای که در لحظهٔ ارسال ساخته می‌شود استفاده نکنید. ارسال دوباره یکی دیگر می‌سازد و حذف تکراری چیزی برای کار کردن ندارد. از زمان هم به همین دلیل استفاده نکنید.

قاعده‌های مقدار: فاصله‌های ابتدا و انتها حذف می‌شود، حداکثر ۲۵۶ بایت، و محدودیت دیگری ندارد. دامنه‌اش حساب شماست، پس با شناسهٔ مشتری دیگری تداخل نمی‌کند.

`POST /v1/events` هیچ حذف تکراری انجام نمی‌دهد. `message_id` را می‌خواند، طولش را بررسی می‌کند، روی رویداد ذخیره‌اش می‌کند، و هرگز چک نمی‌کند که پیش‌تر دیده باشدش. باز هم بفرستیدش تا بعدها بشود تکراری‌ها را پیدا و پاک کرد، ولی انتظار نداشته باشید پلتفرم جلویشان را بگیرد.

## زمان رویداد، عقب‌بردن تاریخ، و قاعدهٔ اختلاف ساعت {#timestamps}

`timestamp` را نگذارید و رویداد با زمان دریافت روی سرور مهر می‌خورد. برای رویدادی که بک‌اند شما همان لحظه منتشر می‌کند این درست است و یک فیلد کمتر برای اشتباه کردن.

`timestamp` را وقتی بگذارید که رویداد در لحظه‌ای غیر از لحظهٔ ارسال اتفاق افتاده: یک جاب که هر ده دقیقه جدول outbox را خالی می‌کند، پرداختی که کال‌بک درگاه یک ساعت طول کشیده تا برسد، مهاجرت سفارش‌های پارسال.

سه قاعده اعمال می‌شود، به همین ترتیب.

بیش از یک ساعت در آینده به زمان دریافت چسبانده می‌شود، با هشدار `timestamp_in_future`. زمانی جلوتر از الان فقط می‌تواند از یک ساعت خراب بیاید، و رد نکردنش یعنی ریختن رویداد داخل بازه‌های گزارشی که مشتری پیش‌تر خوانده و بسته است.

قدیمی‌تر از پنجرهٔ ورود درست به لبهٔ پنجره چسبانده می‌شود، با هشدار `timestamp_too_old`. هرگز رد نمی‌شود. در ورود زندهٔ داده، گوشی‌ای که ساعتش خراب است نباید رویدادهایش را از دست بدهد. پیامدش برای مهاجرت داده سنگین است و ارزش صریح گفتن دارد: دو سال تاریخچه را از یک پنجرهٔ ۳۰ روزه بفرستید و همهٔ رویدادها روی یک لحظهٔ واحد می‌نشینند، پذیرفته‌شده، با پاسخ موفق HTTP، و اولین نشانه‌اش ماه‌ها بعد یک قیف است که هیچ معنایی ندارد.

پنجره یک ثابت سراسری نیست. روی کلکتور همان نگهداشت رویداد خود حساب است: حسابی که رویدادها را برای همیشه نگه می‌دارد ۳۶۵۰ روز می‌گیرد، حسابی که ۹۰ روز تنظیم کرده ۹۰ روز می‌گیرد، و حسابی که روی کف ۳۰ روزه نشسته باز هم همان پیش‌فرض کامل ۳۰ روزه را دارد. روی `POST /v1/events` این پنجره هرگز پر نمی‌شود، پس همیشه همان پیش‌فرض ثابت ۳۰ روزه اعمال می‌شود، هر چیزی که تنظیم نگهداشت شما بگوید.

> [!danger]
> اگر دارید تاریخچه مهاجرت می‌دهید، از `POST /v1/batch` کلکتور استفاده کنید و اول تنظیم نگهداشت رویداد حسابتان را ببینید. `POST /v1/events` هرچه قدیمی‌تر از ۳۰ روز باشد را می‌چسباند بدون اینکه چیزی را رد کند.

بعد نوبت `sent_at` است، و این همان فیلدی است که بیشترین احتمال را دارد یک مهاجرت داده را خراب کند.

`sent_at` برای گوشی‌ای وجود دارد که ساعتش غلط است. SDK می‌گوید فکر می‌کند بسته را چه زمانی فرستاده؛ سرور آن را با زمان واقعی رسیدن مقایسه می‌کند؛ اختلاف را روی زمان رویداد اعمال می‌کند. دستگاهی که ساعتش دو ساعت عقب است و می‌گوید رویداد ساعت ۰۸:۰۰ رخ داده و ساعت ۱۰:۰۰ فرستاده، و بسته‌اش ساعت ۱۲:۰۰ می‌رسد، رویدادش با ۱۰:۰۰ ذخیره می‌شود. تصحیح فقط وقتی اجرا می‌شود که اختلاف از یک دقیقه بیشتر باشد، و فقط اگر زمان تصحیح‌شده هنوز داخل پنجره بیفتد. هیچ هشداری صادر نمی‌شود.

ساعت سرور شما درست است. پس یا `sent_at` را نگذارید، یا برابر همان لحظه‌ای بگذاریدش که در عمل می‌فرستید. کاری که نباید بکنید کپی‌کردن `timestamp` داخل `sent_at` است، که نوشتنش طبیعی به‌نظر می‌رسد و فاجعه است: برای رویدادی مربوط به سه روز پیش، فاصلهٔ «فرستاده» تا «رسیده» سه روز حساب می‌شود، همان سه روز به زمان رویداد اضافه می‌شود، و رویداد روی الان می‌نشیند. کل مهاجرت شما روی امروز فرو می‌ریزد.

```json title="عقب‌بردن درست تاریخ: بدون sent_at"
{
  "batch": [
    {
      "type": "track",
      "message_id": "order-7702-completed",
      "event": "order_completed",
      "user_id": "u_39900",
      "timestamp": "2026-05-14T11:02:00Z",
      "properties": { "order_id": "7702", "revenue": 1250000 }
    }
  ]
}
```

## کدام کد وضعیت را می‌شود دوباره فرستاد {#retries}

| کد | دوباره بفرستم؟ | چرا |
|---|---|---|
| `200` / `202` | نه | کار کرد. قبل از رفتن، خطای هر آیتم را بخوانید |
| `400` | نه | بدنه خراب است و دفعهٔ بعد هم خراب خواهد بود |
| `401` | نه | کلید اشتباه است. تکرار یک سطر لاگ می‌سازد، نه یک راه‌حل |
| `402` | نه | حساب از سقف گذشته. تا کسی پرداخت نکند چیزی عوض نمی‌شود |
| `403` | نه | کلید `profile.write` ندارد. یک نفر باید کلید دیگری صادر کند |
| `404` | نه | روی API مدیریت یعنی مسیر ورود داده اینجا سرو نمی‌شود |
| `413` | نه | بسته بزرگ است. تکه‌اش کنید؛ همان بدنه هرگز رد نمی‌شود |
| `422` | نه | همهٔ رویدادها رد شدند. `error.details` را بخوانید |
| `429` | بله، بعد از `Retry-After: 60` | فقط روی API مدیریت، بودجهٔ این کلید تمام شده |
| `503` | بله | مال ماست نه شما. پایین‌تر را ببینید |
| خطای شبکه بدون پاسخ | بله | ممکن است درخواست هرگز نرسیده باشد |

`402` توضیح جدا لازم دارد، چون شبیه چیزی است که باید دوباره فرستاد و نیست. حساب از سقفی گذشته که خودش خواسته بوده. تکرار تا وقتی فاکتوری پرداخت نشود یا دورهٔ صورتحساب عوض نشود هیچ کاری نمی‌کند. در ضمن باگ سرویس شما هم نیست و نباید به‌عنوان باگ لاگ شود.

روی هیچ‌کدام از دو در، پاسخ `402` عدد ندارد. مقدار مصرف‌شده و سقف سمت ما حساب می‌شوند و همان‌جا دور ریخته می‌شوند، پس «چقدر رد کردم» را فقط از صفحهٔ صورتحساب می‌شود پرسید، نه از این پاسخ.

روی `402` کل بسته رد می‌شود، هرگز بخشی از آن. پذیرش جزئی شما را در وضعیتی می‌گذاشت که نمی‌دانستید کدام آیتم‌ها را باید دوباره بفرستید.

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

## پاسخ ۵۰۳، و اینکه دور انداختنش یعنی از دست دادن داده {#unavailable}

`503` تنها کدی است که می‌گوید خرابی سمت ماست.

همیشه این‌طور نبود، و دلیل اینکه حالا هست ارزش خواندن دارد، چون همین اشتباه در یک کلاینت هم راحت تکرار می‌شود. کلکتور پیش‌تر وقتی جست‌وجوی کلید خودش شکست می‌خورد `401` جواب می‌داد. یک SDK کد `401` را «این کلید هیچ‌وقت کار نخواهد کرد» می‌خواند، متوقف می‌شود، و رویدادها را دور می‌ریزد؛ کد `503` را «بعد دوباره امتحان کن» می‌خواند و نگهشان می‌دارد. پس یک قطعی دیتابیس بی‌صدا رویدادها را سمت مشتری نابود می‌کرد، در حالی که لاگ خود مشتری می‌گفت کلید API‌اش نامعتبر است، که اشتباه‌ترین چیزی است که آدم برود دنبال دیباگش. اندازه‌گیری شد، نه استدلال: با صفر کردن دیتابیس، هشت رویداد از هشت رویداد `401` گرفتند.

با `503` همان‌طور رفتار کنید. بسته را نگه دارید، صبر کنید، دوباره بفرستید. به‌عنوان خطای اعتبارسنجی لاگش نکنید و دورش نیندازید.

کنارش هدر `X-Segmentic-Trace` را هم لاگ کنید: شانزده رقم هگز که هر دو در روی هر پاسخی می‌گذارند و در هیچ بدنهٔ خطایی تکرار نمی‌شود. برای پیگیری یک درخواست مشخص، تنها چیزی است که به کار ما می‌آید. اگر شما خودتان این هدر را روی درخواست بگذارید و مقدارش هگز با طول معقول باشد، همان برگردانده می‌شود.

روی کلکتور دو نوع `503` هست و فقط از روی پاسخ می‌شود از هم تشخیصشان داد:

| | `Retry-After` | پیام | یعنی چه |
|---|---|---|---|
| جست‌وجوی کلید شکست خورد | `5` | `cannot verify the write key right now; retry` | درخواست هرگز به خط لوله نرسید. فرستادن دوبارهٔ همان بدنه درست‌ترین کار است |
| انتشار شکست خورد | ندارد | `temporarily unavailable, please retry` | هم باس رویداد و هم بافر دیسک محلی شکست خوردند |

دومی کمیاب است، چون قطعی باس به‌تنهایی آن را تولید نمی‌کند: کلکتور روی یک لاگ محلی می‌نویسد و وقتی باس برگشت، پخشش می‌کند. برای رسیدن به این حالت باید هر دو خراب شده باشند.

> [!danger]
> در `503` ناشی از شکست انتشار، مقدار `message_id` پیش‌تر در پنجرهٔ حذف تکراری ثبت شده است. ارسال دوباره با همان `message_id` تا ۴۸ ساعت بعد با `200 {"status":"ok","accepted":1}` جواب می‌گیرد و ذخیره نمی‌شود: کلکتور نمی‌تواند این ارسال دوباره را از یک تکراری واقعی تشخیص بدهد. برای اینکه آن رویداد وارد شود، با یک `message_id` متفاوت بفرستیدش و بپذیرید که همین یک رویداد محافظتی در برابر تکرار ندارد. این تنها حالتی است که استفادهٔ دوباره از همان شناسه غلط است.

روی API مدیریت، `503 ingest_unavailable` یعنی صف در دسترس نبود. بسته را دوباره بفرستید. چون آن آدرس حذف تکراری ندارد، `503` بعد از یک انتشار نیمه‌تمام هرچه رفته را دو بار می‌شمارد، که یک دلیل دیگر است برای ترجیح دادن کلکتور در هر چیزی که پول در آن هست.

`503 budget_unavailable` خرابی دیگری با همان کد است: سرویس بودجه خطا داد و API به‌جای اینکه یک کلاینت بی‌شمارش را در حلقه رها کند، بسته عمل می‌کند. با عقب‌نشینی دوباره بفرستید. این یکی همهٔ مسیرهای `api.segmentic.net` را می‌گیرد، از جمله `GET /v1/whoami` را.

## ترتیب {#ordering}

یک تضمین، و ارزش دارد بدانید تا کجا می‌رود.

هر رویداد با کلید پارتیشن `user_id` خودش روی باس گذاشته می‌شود، و اگر `user_id` نداشته باشد با `anonymous_id`. پس همهٔ رویدادهای یک نفر روی یک پارتیشن می‌نشینند، و مصرف‌کننده‌هایی که برای هر نفر حالت نگه می‌دارند (به‌روزرسانی پرونده، حالت سناریو، دوختن نشست) آن‌ها را به همان ترتیبی می‌بینند که باس پذیرفته، بدون هیچ هماهنگی بین پارتیشن‌ها.

چه چیزی از این نتیجه می‌شود و چه چیزی نمی‌شود:

- داخل یک بسته، آیتم‌ها به ترتیب آرایه منتشر می‌شوند، پس دو رویداد یک نفر در یک بسته ترتیبشان را حفظ می‌کنند.
- بین دو درخواست، ترتیب همان ترتیب پذیرفته‌شدن درخواست‌هاست. دو درخواست همزمان از دو ورکر هیچ ترتیبی نسبت به هم ندارند.
- عوض‌شدن هویت یعنی عوض‌شدن پارتیشن. رویدادی که با `anonymous_id` رفته و رویداد بعدی که با `user_id` رفته روی دو پارتیشن‌اند و هیچ ترتیبی نسبت به هم ندارند. `alias` و `identify` برای همین وجود دارند؛ [هویت](/docs/identity) را ببینید.
- ترتیب در قطعی باس حفظ نمی‌شود. رویدادهایی که در لاگ محلی بافر شده‌اند روی یک تایمر و حدود هر پنج ثانیه پخش می‌شوند، پس رویدادی که در زمان قطعی پذیرفته شده می‌تواند بعد از رویدادهای بعد از خودش به باس برسد.

اگر ترتیب دو رویداد برای یک گزارش مهم است، به ترتیب ارسال تکیه نکنید. روی هر دو `timestamp` بگذارید.

## سقف ماهانه و بودجهٔ درخواست {#quota}

دو محدودیت متفاوت، روی دو در متفاوت، با دو کد وضعیت متفاوت.

سقف ماهانهٔ رویداد روی هر دو اعمال می‌شود. یک بار برای کل بسته و قبل از هر کار روی آیتم‌ها بررسی می‌شود و با `402` رد می‌کند. باز عمل می‌کند: اگر خود بررسی خطا بدهد بسته پذیرفته می‌شود، چون از دست دادن دادهٔ یک مشتری وقتی دیتابیس یک لحظه پلک می‌زند، حادثه‌ای به‌مراتب بزرگ‌تر از یک صورتحساب از کنترل خارج‌شده است.

بودجهٔ درخواست فقط روی `api.segmentic.net` است. وزنی است نه شمارشی، چون یک فراخوان یک ساختار را می‌خواند و فراخوان بعدی کل انبار داده را اسکن می‌کند. `POST /v1/events` پنج واحد خرج دارد. سهمیه ۶۰۰ واحد در دقیقه است و به‌ازای هر کلید API حساب می‌شود نه هر حساب، تا یک ایجنت از کنترل خارج‌شده نتواند بودجه‌ای را که خط لولهٔ سفارش شما به آن وابسته است تمام کند. یعنی ۱۲۰ فراخوان ورود داده در دقیقه برای هر کلید، یا ۶۰۰۰۰ رویداد در دقیقه با بیشترین اندازهٔ بسته.

پنجره یک دقیقهٔ تقویمی ثابت است نه لغزان، و روی بودجه هیچ هدر `X-RateLimit-*` وجود ندارد. نمی‌توانید بپرسید چقدر از آن برایتان مانده، و `GET /v1/whoami` هم گزارشش نمی‌کند. وقتی تمام شد `429` می‌گیرید با `Retry-After: 60` که محافظه‌کارانه است: ممکن است دقیقه زودتر بچرخد.

کلکتور نه بودجهٔ درخواست دارد و نه هیچ نوع محدودکنندهٔ نرخی. تنها کنترل حجم روی آن، همان سقف ماهانه است.

## User-Agent شما، و پرچم ربات {#user-agent}

کلکتور آی‌پی و `User-Agent` را از خود اتصال برمی‌دارد، هرگز از بدنه، تا کلاینت نتواند موقعیت جغرافیایی یا دستگاه خودش را جعل کند. از مرورگر، این تنها راه فهمیدن این است که بازدیدکننده روی چه چیزی است. از بک‌اند شما یعنی رویداد با مهر دیتاسنتر شما و کلاینت HTTP شما ذخیره می‌شود.

بیشترش بی‌ضرر است. مقدار `browser_name` می‌شود `python-requests` یا `Go-http-client` که نامرتب است و چیزی به شما نمی‌گوید که ندانید.

یک حالت بی‌ضرر نیست. تجزیه‌کنندهٔ User-Agent وقتی رشتهٔ `User-Agent` یک نشانی وب داخلش داشته باشد، درخواست را ربات علامت می‌زند، و جدا از آن وقتی نام تجزیه‌شده رشتهٔ `bot` را در خود داشته باشد. همهٔ گزارش‌ها، همهٔ کاشی‌های داشبورد و کامپایلر سگمنت روی `is_bot = 0` فیلتر می‌کنند. پس یک سرویس خوش‌رفتار که مؤدبانه خودش را همان‌طور معرفی می‌کند که از یک کلاینت HTTP انتظار می‌رود:

```text
User-Agent: myshop-orders/1.0 (+https://myshop.ir)
```

رویدادهایی تولید می‌کند که ذخیره می‌شوند، حساب می‌شوند، در تایم‌لاین خام کاربر دیده می‌شوند، و در هیچ گزارشی و هیچ سگمنتی دیده نمی‌شوند. هیچ خطایی نمی‌دهد. رویدادها فقط وقتی تیم بازاریابی نگاه می‌کند آنجا نیستند.

یک عبارت ساده بدون نشانی وب بفرستید، و رشتهٔ `bot` را در نام نیاورید:

```text
User-Agent: myshop-orders/1.0
```

نفرستادن این هدر هم جواب می‌دهد: با نبودن `User-Agent` تجزیه‌کننده اجرا نمی‌شود و هیچ‌کدام از فیلدهای دستگاه دست نمی‌خورند. `POST /v1/events` این هدر را هرگز نمی‌خواند، پس تمام این بخش به آن ربطی ندارد.

## چیزهایی که وجود ندارند {#gaps}

نوشته شده چون فهمیدنشان با آزمایش، یک بعدازظهر خرج دارد.

- فشرده‌سازی نیست. هیچ‌کدام از دو آدرس `Content-Encoding` را نمی‌خوانند، پس بدنهٔ gzip شده `400 malformed JSON` می‌گیرد.
- روی `POST /v1/events` نه حذف تکراری هست و نه کلید idempotency. هدر `Idempotency-Key` فقط روی یک آدرس در کل این API کار می‌کند، `POST /v1/messages`، و جای دیگری نه.
- `POST /v1/events` هشدار برنمی‌گرداند. هشدارها حساب و دور ریخته می‌شوند.
- هیچ راهی برای خواندن دوبارهٔ یک رویداد، ویرایش یا حذف آن نیست. `GET /v1/events` وجود ندارد. اصلاح با فرستادن یک رویداد جبرانی انجام می‌شود.
- روی `503` ناشی از شکست انتشار در کلکتور، هدر `Retry-After` نیست. فقط `503` جست‌وجوی کلید آن را دارد.
- روی کلکتور هیچ محدودیت نرخی نیست: نه سقف در ثانیه، نه کنترل انفجار ترافیک، نه سقف همزمانی برای هر حساب.
- هیچ آدرس مهاجرت داده‌ای از `api.segmentic.net` در دسترس نیست. آدرس `POST /v1/import/events` که زمان خارج از پنجره را به‌جای چسباندن رد می‌کند، فقط روی API داخلی داشبورد ثبت شده و عمومی مسیریابی نمی‌شود. `/v1/batch` کلکتور و پنجرهٔ نگهداشت خود حساب، تنها راه دادهٔ قدیمی‌اند.
- در نسخهٔ مستقرشده مکان‌یابی از روی آی‌پی نیست. مقادیر `country` و `region` و `city` فقط از چیزی پر می‌شوند که شما در `context.location` می‌فرستید.
- روی `api.segmentic.net` پاسخ preflight برای CORS نیست. مرورگر نمی‌تواند API مدیریت را صدا بزند؛ کلید نوشتن و کلکتور برای همین‌اند.
- روی بودجهٔ درخواست هدرهای `X-RateLimit-Limit` و `X-RateLimit-Remaining` نیست.
- پاسخ `402` کلکتور انگلیسی ندارد. همیشه فارسی است.
- برای ترافیک بسته‌ای، دیباگر زندهٔ رویداد کار نمی‌کند. دیباگر داشبورد ارسال‌های تک‌رویدادی و رویدادهای وبهوک را ضبط می‌کند؛ `/v1/batch` چیزی به آن نمی‌دهد، پس یک سرویس سروری که بسته می‌فرستد آنجا چیزی نمی‌بیند.

## یک برنامهٔ کامل به زبان Go {#example-go}

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

```go title="main.go"
// Sends completed orders to Segmentic from a Go backend.
//
//	export SEGMENTIC_WRITE_KEY=wk_seg_...
//	go run main.go
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strings"
	"time"
)

const (
	ingestURL    = "https://in.segmentic.net/v1/batch"
	maxBatchSize = 500
)

type envelope struct {
	Type       string         `json:"type"`
	MessageID  string         `json:"message_id"`
	Event      string         `json:"event,omitempty"`
	UserID     string         `json:"user_id"`
	Timestamp  *time.Time     `json:"timestamp,omitempty"`
	Properties map[string]any `json:"properties,omitempty"`
	Traits     map[string]any `json:"traits,omitempty"`
}

// No sent_at field. It is for correcting a wrong device clock, and setting it
// from a server whose clock is right can only move timestamps that were
// already correct.
type batch struct {
	Batch   []envelope     `json:"batch"`
	Context map[string]any `json:"context,omitempty"`
}

type itemError struct {
	Index  int    `json:"index"`
	Reason string `json:"reason"`
}

type reply struct {
	Status   string      `json:"status"`
	Accepted int         `json:"accepted"`
	Rejected int         `json:"rejected"`
	Errors   []itemError `json:"errors"`
	Message  string      `json:"message"`
}

// errRetryable marks a failure that a second attempt can fix.
var errRetryable = errors.New("segmentic: temporarily unavailable")

// errBurnt marks the one 503 where the message_id has already been consumed:
// the publish failed after de-duplication recorded the ids, so an identical
// retry is answered 200 and stored nowhere.
var errBurnt = errors.New("segmentic: publish failed; message ids are spent")

func post(client *http.Client, key string, events []envelope) (reply, error) {
	if len(events) > maxBatchSize {
		return reply{}, fmt.Errorf("segmentic: %d events, limit %d", len(events), maxBatchSize)
	}

	body, err := json.Marshal(batch{
		Batch:   events,
		Context: map[string]any{"locale": "fa-IR"},
	})
	if err != nil {
		return reply{}, err
	}

	req, err := http.NewRequest(http.MethodPost, ingestURL, bytes.NewReader(body))
	if err != nil {
		return reply{}, err
	}
	req.Header.Set("Authorization", "Bearer "+key)
	req.Header.Set("Content-Type", "application/json")
	// A plain token. A User-Agent containing a URL makes the parser flag the
	// event as a bot, and every report filters bots out.
	req.Header.Set("User-Agent", "myshop-orders/1.0")

	res, err := client.Do(req)
	if err != nil {
		// The request may never have arrived, so the ids are still free.
		return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
	}
	defer res.Body.Close()

	raw, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
	if err != nil {
		return reply{}, fmt.Errorf("%w: %v", errRetryable, err)
	}

	var out reply
	if err := json.Unmarshal(raw, &out); err != nil {
		return reply{}, fmt.Errorf("segmentic: unreadable reply, status %d: %s", res.StatusCode, raw)
	}

	switch res.StatusCode {
	case http.StatusOK:
		return out, nil
	case http.StatusServiceUnavailable:
		if res.Header.Get("Retry-After") != "" {
			// The key lookup failed. Nothing reached the pipeline.
			return out, fmt.Errorf("%w: %s", errRetryable, out.Message)
		}
		return out, fmt.Errorf("%w: %s", errBurnt, out.Message)
	default:
		// 400, 401, 402 and 413 all say the same thing on a second attempt.
		return out, fmt.Errorf("segmentic: %d %s", res.StatusCode, out.Message)
	}
}

func main() {
	key := os.Getenv("SEGMENTIC_WRITE_KEY")
	if key == "" {
		log.Fatal("SEGMENTIC_WRITE_KEY is not set")
	}

	paidAt := time.Now().UTC().Add(-45 * time.Minute)
	events := []envelope{
		{
			Type: "track",
			// Derived from the order, so a retry computes the same string.
			MessageID: "order-8821-completed",
			Event:     "order_completed",
			UserID:    "u_44120",
			Timestamp: &paidAt,
			Properties: map[string]any{
				"order_id": "8821",
				"revenue":  4800000,
				"currency": "IRR",
				"city":     "تهران",
			},
		},
		{
			Type:      "identify",
			MessageID: "profile-44120-v7",
			UserID:    "u_44120",
			Traits: map[string]any{
				"phone":      "09123456789",
				"first_name": "سارا",
				"city":       "تهران",
			},
		},
	}

	client := &http.Client{Timeout: 15 * time.Second}

	var out reply
	var err error
	for attempt := 1; attempt <= 5; attempt++ {
		out, err = post(client, key, events)
		if err == nil || !errors.Is(err, errRetryable) {
			break
		}
		wait := time.Duration(1<<attempt) * time.Second
		log.Printf("attempt %d failed (%v); waiting %s", attempt, err, wait)
		time.Sleep(wait)
	}
	if err != nil {
		log.Fatalf("segmentic: giving up: %v", err)
	}

	log.Printf("accepted %d, rejected %d", out.Accepted, out.Rejected)
	for _, e := range out.Errors {
		// Split on ": " because unknown_type carries the offending value.
		code, _, _ := strings.Cut(e.Reason, ": ")
		log.Printf("item %d (%s) rejected: %s", e.Index, events[e.Index].MessageID, code)
	}
}
```

## یک برنامهٔ کامل به زبان Python {#example-python}

جدول outbox را خالی می‌کند و در تکه‌های ۵۰۰ تایی به کلکتور می‌فرستد، با زمان‌های عقب‌برده و بدون `sent_at`. به `requests` نیاز دارد.

```python title="send_orders.py"
#!/usr/bin/env python3
"""Send an outbox of paid orders to Segmentic.

    pip install requests
    export SEGMENTIC_WRITE_KEY=wk_seg_...
    python send_orders.py
"""

import os
import sys
import time
from datetime import datetime, timedelta, timezone

import requests

INGEST_URL = "https://in.segmentic.net/v1/batch"
MAX_BATCH = 500
# Everything else means the payload or the credential is wrong, and a second
# attempt sends the same wrong thing.
RETRYABLE = {408, 500, 502, 503, 504}


def rfc3339(moment: datetime) -> str:
    """The only timestamp format the ingest endpoint decodes."""
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def chunks(items, size):
    for start in range(0, len(items), size):
        yield items[start:start + size]


def post_batch(session: requests.Session, key: str, events: list) -> dict:
    """POST one batch. Returns the decoded reply or raises."""
    if len(events) > MAX_BATCH:
        raise ValueError(f"{len(events)} events, limit {MAX_BATCH}")

    response = session.post(
        INGEST_URL,
        # No sent_at anywhere: these events are backdated, and sent_at would
        # be read as clock skew and move every one of them to now.
        json={"batch": events, "context": {"locale": "fa-IR"}},
        headers={
            "Authorization": f"Bearer {key}",
            "Content-Type": "application/json",
            # No URL in the token: a User-Agent containing one makes the
            # event count as bot traffic, which every report filters out.
            "User-Agent": "myshop-outbox/1.0",
        },
        timeout=20,
    )

    try:
        body = response.json()
    except ValueError:
        body = {"message": response.text[:500]}

    if response.status_code == 200:
        return body
    if response.status_code in RETRYABLE:
        raise ConnectionError(f"{response.status_code}: {body.get('message')}")
    raise RuntimeError(f"{response.status_code}: {body.get('message')}")


def send_with_retries(session, key, events, attempts=5):
    for attempt in range(1, attempts + 1):
        try:
            return post_batch(session, key, events)
        except (ConnectionError, requests.RequestException) as exc:
            if attempt == attempts:
                raise
            wait = 2 ** attempt
            print(f"attempt {attempt} failed ({exc}); waiting {wait}s", file=sys.stderr)
            time.sleep(wait)


def main() -> int:
    key = os.environ.get("SEGMENTIC_WRITE_KEY")
    if not key:
        print("SEGMENTIC_WRITE_KEY is not set", file=sys.stderr)
        return 1

    # Stand-in for the rows your own outbox query returns.
    now = datetime.now(timezone.utc)
    orders = [
        {"id": 8821, "user": "u_44120", "rial": 4800000, "paid": now - timedelta(hours=3)},
        {"id": 8822, "user": "u_39900", "rial": 1250000, "paid": now - timedelta(hours=2)},
        {"id": 8823, "user": "", "rial": 990000, "paid": now - timedelta(hours=1)},
    ]

    events = [
        {
            "type": "track",
            # Deterministic, so a retry produces the same id and the second
            # delivery is de-duplicated instead of counted again.
            "message_id": f"order-{order['id']}-completed",
            "event": "order_completed",
            "user_id": order["user"],
            "timestamp": rfc3339(order["paid"]),
            "properties": {
                "order_id": str(order["id"]),
                "revenue": order["rial"],
                "currency": "IRR",
            },
        }
        for order in orders
    ]

    session = requests.Session()
    failures = 0

    for part in chunks(events, MAX_BATCH):
        reply = send_with_retries(session, key, part)
        print(f"accepted {reply.get('accepted', 0)}, rejected {reply.get('rejected', 0)}")

        for problem in reply.get("errors", []):
            # Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
            code = problem["reason"].split(": ", 1)[0]
            bad = part[problem["index"]]
            print(f"  {bad['message_id']}: {code}", file=sys.stderr)
            failures += 1

        for note in reply.get("warnings", []):
            print(f"  warning {note['code']}: {note.get('note', '')}", file=sys.stderr)

    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())
```

## یک برنامهٔ کامل به زبان PHP {#example-php}

مسیر API مدیریت، برای بک‌اندی که همین حالا یک کلید `sk_seg_` دارد. فقط به `ext-curl` و `ext-json` نیاز دارد.

```php title="send_events.php"
<?php
/**
 * Send events to Segmentic's management API from PHP.
 *
 *   SEGMENTIC_API_KEY=sk_seg_... php send_events.php
 *
 * This endpoint does not de-duplicate. If this script can run twice over the
 * same rows, mark them as sent in your own database inside a transaction.
 */

declare(strict_types=1);

const EVENTS_URL = 'https://api.segmentic.net/v1/events';
const MAX_BATCH  = 500;

/**
 * POST one batch. Returns the decoded 202 body.
 *
 * @throws RuntimeException with the HTTP status as its code.
 */
function segmenticSend(string $key, array $events): array
{
    if (count($events) > MAX_BATCH) {
        throw new RuntimeException(count($events) . ' events, limit ' . MAX_BATCH, 413);
    }

    $payload = json_encode(
        ['events' => $events],
        JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
    );

    $curl = curl_init(EVENTS_URL);
    curl_setopt_array($curl, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 30,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . $key,
            'Content-Type: application/json',
        ],
    ]);

    $raw    = curl_exec($curl);
    $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $error  = curl_error($curl);
    curl_close($curl);

    if ($raw === false) {
        // No response at all. Treated as retryable: the request may never
        // have arrived.
        throw new RuntimeException('transport: ' . $error, 503);
    }

    $body = json_decode($raw, true);
    if (!is_array($body)) {
        throw new RuntimeException('unreadable reply: ' . substr($raw, 0, 300), $status);
    }

    if ($status === 202) {
        return $body;
    }

    // error.code is the contract. error.message is prose and will be reworded.
    // On ten other routes of this API, error is a plain string instead, so
    // read its type before reaching into it.
    $code = is_array($body['error'] ?? null)
        ? ($body['error']['code'] ?? 'unknown')
        : (string) ($body['error'] ?? 'unknown');

    throw new RuntimeException($code, $status);
}

$key = getenv('SEGMENTIC_API_KEY');
if ($key === false || $key === '') {
    fwrite(STDERR, "SEGMENTIC_API_KEY is not set\n");
    exit(1);
}

// No batch-level context or sent_at on this endpoint: those fields exist only
// on the collector, so anything shared has to be repeated per item.
$events = [
    [
        'type'       => 'track',
        'message_id' => 'order-8821-completed',
        'event'      => 'order_completed',
        'user_id'    => 'u_44120',
        'timestamp'  => gmdate('Y-m-d\TH:i:s\Z', time() - 1800),
        'properties' => [
            'order_id' => '8821',
            'revenue'  => 4800000,
            'currency' => 'IRR',
            'city'     => 'تهران',
        ],
        'context'    => ['locale' => 'fa-IR'],
    ],
    [
        'type'       => 'identify',
        'message_id' => 'profile-44120-v7',
        'user_id'    => 'u_44120',
        'traits'     => ['phone' => '09123456789', 'city' => 'تهران'],
        'context'    => ['locale' => 'fa-IR'],
    ],
];

$attempt = 0;
while (true) {
    $attempt++;
    try {
        $reply = segmenticSend($key, $events);
        break;
    } catch (RuntimeException $e) {
        // 429 carries Retry-After: 60. 503 is ours. Everything else is fixed
        // by changing the request, not by repeating it.
        $retryable = in_array($e->getCode(), [429, 503], true);
        if (!$retryable || $attempt >= 5) {
            fwrite(STDERR, 'segmentic refused: ' . $e->getCode() . ' ' . $e->getMessage() . "\n");
            exit(1);
        }
        $wait = $e->getCode() === 429 ? 60 : 2 ** $attempt;
        fwrite(STDERR, "attempt {$attempt}: {$e->getMessage()}; waiting {$wait}s\n");
        sleep($wait);
    }
}

printf("accepted %d\n", $reply['accepted'] ?? 0);

foreach ($reply['rejected'] ?? [] as $item) {
    // Prefix match: unknown_type arrives as 'unknown_type: "trak"'.
    $code = explode(': ', $item['reason'], 2)[0];
    $bad  = $events[$item['index']]['message_id'] ?? '(no message_id)';
    fwrite(STDERR, "rejected {$bad}: {$code}\n");
}
```

## توسعهٔ محلی {#local}

کلکتور روی `http://localhost:8080` گوش می‌دهد و `/v1/batch` را همان‌جا سرو می‌کند، بدون هیچ تغییری در بدنه.

API مدیریت داستان دیگری دارد. روی آدرسی سرو می‌شود که در `PUBLIC_API_ADDR` نوشته شده، و مقدار پیش‌فرض آن خالی است، پس روی یک نصب تازه API عمومی سرو نمی‌شود. هیچ چیزی گوش نمی‌دهد، و اولین نشانه‌اش یک connection refused است که شبیه مشکل شبکه به نظر می‌رسد. مقدارش را بگذارید، ری‌استارت کنید، و قبل از اینکه دنبال هر چیز دیگری بگردید با `GET /v1/status` چک کنید.

```bash title="آیا API عمومی بالاست؟"
curl -sS http://localhost:8082/v1/status
```

```json
{ "status": "ok", "service": "api", "version": "dev" }
```

`GET /v1/status` تنها مسیری روی API مدیریت است که کلید نمی‌خواهد و از قطعی ردیس جان سالم به در می‌برد. بقیه، از جمله `GET /v1/whoami`، از بودجهٔ درخواست رد می‌شوند و بودجه بسته عمل می‌کند.

## بعدش کجا {#next}

- [رویدادها](/docs/events) برای کل ساختار پاکت، از جمله تمام شیء `context`.
- [فرهنگ رویدادها](/docs/event-dictionary) برای نام‌ها و ویژگی‌های استانداردی که قیف‌های آماده را بدون پیکربندی به کار می‌اندازند.
- [هویت](/docs/identity) برای `user_id` و `anonymous_id` و `alias`.
- [خطاها](/docs/errors) برای همهٔ کدها روی هر دو سطح.
- [محدودیت‌ها](/docs/limits) برای هر عددی که پلتفرم شما را به آن پایبند می‌کند.
- [API مدیریت](/docs/api/management) برای سگمنت و کمپین و گزارش.

---

# کاتالوگ محصولات: پر کردنش و نگه داشتنش

> فید دوره‌ای، بارگذاری فایل و یادگیری از سفارش‌ها. سه قالب فید، واحد قیمت، و شناسه‌ای که اگر با رویدادها یکی نباشد هیچ پیشنهادی ساخته نمی‌شود.
> https://segmentic.net/docs/catalog

کاتالوگ، فهرست محصولات شماست همان‌طور که سگمنتیک می‌بیندشان: شناسه، نام، قیمت، تصویر، لینک و موجودی. تا وقتی این فهرست خالی باشد، بلوک «پیشنهاد محصول» در ایمیل‌ها هیچ چیزی نشان نمی‌دهد و هیچ خطایی هم نمی‌دهد؛ فقط یک قسمت خالی. این صفحه می‌گوید چطور پرش کنید و کدام تصمیم‌ها بی‌سروصدا خرابش می‌کنند.

## کاتالوگ به چه کار می‌آید {#what-it-is}

> نمودار: تبدیل فید محصول، فایل و رویداد فروشگاه به پیشنهاد و محتوای اختصاصی

به سه چیز، و هر سه فقط با کاتالوگ کار می‌کنند:

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

نکتهٔ آخر مهم‌تر از آن است که به نظر می‌رسد: تاریخچهٔ رفتار از رویدادها می‌آید، ولی هر شناسه‌ای که در کاتالوگ نباشد از فهرست پیشنهادها **حذف می‌شود**. یعنی حسابی با میلیون‌ها رویداد `product_viewed` و کاتالوگ خالی، صفر پیشنهاد تولید می‌کند.

## شناسه‌ای که باید یکی باشد {#the-id}

این مهم‌ترین جملهٔ این صفحه است.

**شناسهٔ محصول در کاتالوگ باید بایت‌به‌بایت همان چیزی باشد که رویدادهایتان در `product_id` می‌فرستند.**

اگر سایت `SKU-1024` می‌فرستد و فید `1024` می‌دهد، هر دو طرف درست به نظر می‌رسند: کاتالوگ پر است، رویدادها می‌آیند، هیچ خطایی در هیچ لاگی نیست، و هیچ پیشنهادی هرگز ساخته نمی‌شود. این تنها اشتباهی است در کل این صفحه که هیچ نشانه‌ای از خودش نشان نمی‌دهد.

پیش از پر کردن کاتالوگ، یک بار [رویدادهایتان](/docs/instrument#verify-each-one) را در صفحهٔ «داده» باز کنید و ببینید `product_id` واقعا چه شکلی است. بعد فید را با همان شکل بسازید.

## سه راه پر کردن {#three-ways}

| راه | برای چه کسی | چند وقت یک بار |
| --- | --- | --- |
| فید دوره‌ای | فروشگاهی که آدرس فید دارد یا می‌تواند بسازد | هر شش ساعت، خودکار |
| بارگذاری فایل در پنل | کاتالوگ کوچک یا ثابت، یا شروع سریع | دستی |
| وب‌هوک‌های فروشگاه | هر حسابی که شاپیفای، ووکامرس یا دیجی‌کالا وصل کرده | با هر سفارش |

این سه با هم تداخل ندارند و می‌توانید هر سه را داشته باشید. ترتیب اولویتشان در بخش [وقتی چند منبع دارید](/docs/catalog#webhooks) توضیح داده شده.

## فید دوره‌ای {#feed}

در پنل، «کاتالوگ» و بعد «فید محصولات». آدرس فید و قالبش را بدهید و فعالش کنید.

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

سه محدودیت که خوب است بدانید: هر بار حداکثر دو دقیقه فرصت دارد، حداکثر ۶۴ مگابایت خوانده می‌شود، و حداکثر دویست هزار محصول از یک فایل برداشته می‌شود.

> [!warn]
> آدرس فید باید عمومی باشد. آدرسی که به شبکهٔ داخلی، به `localhost` یا به رنج‌های خصوصی برسد رد می‌شود، و ریدایرکت به چنین آدرسی هم رد می‌شود. این یک محدودیت عمدی است، نه یک نقص: سروری که آدرس دلخواه شما را واکشی می‌کند، بدون این بررسی یک ابزار حمله به شبکهٔ داخلی ماست.

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

## قالب CSV {#csv}

سطر اول باید نام ستون‌ها باشد. این نام‌ها شناخته می‌شوند و بقیه نادیده گرفته می‌شوند:

| فیلد | نام‌های پذیرفته‌شده | لازم |
| --- | --- | --- |
| شناسه | `sku`, `id`, `product_id`, `code` | بله |
| نام | `title`, `name`, `product_name` | بله |
| قیمت | `price`, `price_rial`, `amount` | برای دیده شدن، بله |
| قیمت پیش از تخفیف | `compare_at_price`, `old_price`, `list_price` | نه |
| دسته | `category`, `product_type`, `categories` | نه |
| برند | `brand`, `manufacturer`, `vendor` | نه |
| تصویر | `image`, `image_url`, `image_link` | نه |
| آدرس صفحه | `url`, `link`, `product_url`, `permalink` | نه |
| موجودی | `in_stock`, `stock`, `availability`, `quantity` | نه |
| توضیح | `description`, `desc` | نه |

یک سطر خراب کل فایل را رد نمی‌کند: شمرده می‌شود و بقیه وارد می‌شوند. اگر ستون شناسه یا ستون نام اصلا پیدا نشود، آن‌وقت کل فایل رد می‌شود، چون فایلی بدون این دو، فایل اشتباهی است نه فروشگاه خالی.

## قالب JSON {#json}

یا یک آرایه، یا یک شیء با کلید `products`:

```json
{
  "products": [
    {
      "sku": "SHOE-1024",
      "title": "کفش ورزشی مدل رانر",
      "price_rial": 24000000,
      "compare_at_rial": 30000000,
      "category": "کفش",
      "brand": "نایک",
      "image_url": "https://shop.example.ir/img/1024.jpg",
      "url": "https://shop.example.ir/p/1024",
      "in_stock": true
    }
  ]
}
```

`name` به جای `title`، `id` یا `product_id` به جای `sku`، `image` به جای `image_url` و `link` به جای `url` هم پذیرفته می‌شوند.

اگر `in_stock` را ننویسید، محصول موجود حساب می‌شود. این عمدی است: فروشگاهی که فقط چیزهای فروختنی‌اش را export می‌کند حالت رایج است، و خواندن نبودن این فیلد به معنی ناموجود یعنی وارد کردن کاتالوگی که هیچ‌چیزش قابل پیشنهاد نیست.

## قالب XML، یعنی گوگل مرچنت {#xml}

اگر فروشگاهتان برای سایت‌های مقایسهٔ قیمت فید می‌دهد، همان فید احتمالا همین است و لازم نیست چیز تازه‌ای بسازید.

```xml
<rss version="2.0" xmlns:g="http://base.google.com/ns/1.0">
  <channel>
    <item>
      <g:id>SHOE-1024</g:id>
      <g:title>کفش ورزشی مدل رانر</g:title>
      <g:price>24000000 IRR</g:price>
      <g:sale_price>19000000 IRR</g:sale_price>
      <g:availability>in stock</g:availability>
      <g:image_link>https://shop.example.ir/img/1024.jpg</g:image_link>
      <g:link>https://shop.example.ir/p/1024</g:link>
      <g:brand>نایک</g:brand>
      <g:product_type>کفش</g:product_type>
    </item>
  </channel>
</rss>
```

وقتی `sale_price` باشد و از `price` کمتر باشد، همان قیمت فروش است و `price` به خط خوردهٔ «قیمت قبل» تبدیل می‌شود. `availability` مقدارهای خود استاندارد را می‌گیرد، و `out of stock` تنها مقداری است که محصول را ناموجود می‌کند.

XML تنها قالبی است که واحد پول را خودش حمل می‌کند، و همین باعث می‌شود کم‌ریسک‌ترین قالب باشد.

## قیمت و واحدش {#prices}

قیمت در دیتابیس **ریال** ذخیره می‌شود و در پیام‌ها به **تومان** نمایش داده می‌شود.

این تنها جایی است که یک اشتباه ساکت، عددی غلط را به دست مشتری شما می‌رساند، پس قاعده‌اش سخت‌گیرانه است:

| قالب | چطور خوانده می‌شود |
| --- | --- |
| CSV و JSON | عدد را **ریال** فرض می‌کند. اگر فایل شما تومان است، در خود فایل ضربدر ده کنید. |
| XML با `IRR` | ریال |
| XML با `IRT` یا `TOMAN` | ضربدر ده |
| XML با هر واحد دیگری | **بدون قیمت** ذخیره می‌شود |

سطر آخر عمدی است. محصولی که قیمت ندارد در پنل دیده می‌شود و در هیچ پیامی نمی‌آید، یعنی یک جای خالی که کسی پیدایش می‌کند. قیمتی که ده برابر غلط باشد عددی است که کسی به آن شک نمی‌کند و روی آن سفارش می‌دهد.

جداکننده‌ها اهمیتی ندارند: `۲۴۰۰۰۰۰۰`، `24,000,000` و `24.000.000` هر سه یک عدد خوانده می‌شوند.

> [!note]
> محصولی با قیمت صفر هرگز پیشنهاد نمی‌شود. اگر کاتالوگ پر است و پیشنهادها خالی‌اند، اول ستون قیمت را نگاه کنید.

## موجودی، و محصولی که از فید می‌رود {#stock}

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

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

یک محافظ هم هست که خوب است بدانید وجود دارد: اگر یک اجرا کمتر از نصف اجرای موفق قبلی محصول بخواند، محصولات خوانده‌شده نوشته می‌شوند و **هیچ چیزی ناموجود نمی‌شود**. فایل نصفه، صفحهٔ ورود به‌جای فید، یا export ناتمام، هیچ‌کدام نباید بتوانند یک کاتالوگ سالم را ساعت سه بامداد خالی کنند.

## بارگذاری از فایل در پنل {#import}

در صفحهٔ «فید محصولات»، زیر تنظیمات فید. یک فایل CSV بدهید، ستون‌ها را که خودش حدس زده اصلاح کنید، و پیش از ارسال ببینید چند محصول ساخته شده و اولین‌شان چه قیمتی گرفته است.

دو چیز که این صفحه دارد و فید ندارد: واحد قیمت را همان‌جا انتخاب می‌کنید (تومان یا ریال)، و شناسهٔ تکراری داخل یک فایل را با شمارهٔ سطرش به شما نشان می‌دهد.

فایل بزرگ خودش تکه‌تکه فرستاده می‌شود و نوار پیشرفت را می‌بینید. اگر وسط کار شکست بخورد، متوقف می‌شود و می‌گوید تا آن لحظه چند محصول وارد شده، چون واردات نیمه‌تمامی که کسی فکر کند کامل است بدتر از واردات شکست‌خورده است.

## پیش‌نمایش پیشنهاد برای یک کاربر {#preview}

در پایین صفحهٔ «کاتالوگ»، بخش پیش‌نمایش همیشه در دسترس است. با نوشتن دو کاراکتر، جستجو هم‌زمان بین شناسه، شماره موبایل و ایمیل انجام می‌شود؛ مثلا `09` هم شماره‌های دارای این عبارت را پیدا می‌کند و هم شناسه یا ایمیلی را که `09` در آن باشد. کاربران همان لحظه زیر فیلد جستجو نمایش داده می‌شوند. یک نفر را انتخاب کنید، اولویت پیشنهاد را روی حالت خودکار بگذارید یا یک اولویت مشخص انتخاب کنید، سپس نتیجه را پیش از ارسال ببینید.

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

## از وب‌هوک‌های فروشگاه {#webhooks}

اگر شاپیفای، ووکامرس یا دیجی‌کالا را وصل کرده‌اید، هر سفارشی که می‌آید نام و شناسه و قیمت محصولاتش را هم دارد. این‌ها خودبه‌خود وارد کاتالوگ می‌شوند، بدون هیچ تنظیمی.

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

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

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

## همگام‌سازی از بک‌اند {#no-public-api}

برای همگام‌سازی خودکار، داده کاتالوگ را به‌شکل یک فید JSON روی یک نشانی HTTPS منتشر کنید و آن نشانی را در پنل بدهید. سگمنتیک فید را دوره‌ای دریافت می‌کند و وضعیت آخرین اجرا را در همان صفحه نشان می‌دهد.

## عیب‌یابی سریع {#when-it-goes-wrong}

| نشانه | محتمل‌ترین دلیل |
| --- | --- |
| کاتالوگ پر است، پیشنهادها خالی | شناسه‌ها با `product_id` رویدادها یکی نیستند. [بخش شناسه](/docs/catalog#the-id) |
| بعضی محصولات هرگز پیشنهاد نمی‌شوند | قیمتشان صفر است یا ناموجودند |
| قیمت‌ها ده برابرند | فایل تومان بوده و ریال خوانده شده. [بخش قیمت](/docs/catalog#prices) |
| فید ذخیره می‌شود ولی هیچ اجرایی نمی‌بینم | فید فعال نیست، یا آدرسش خصوصی است و رد شده. متن خطا بالای همان صفحه است |
| کارت سلامت فید «نیازمند بررسی» است | بیش از دو روز است هیچ اجرای موفقی نبوده. دلیلش در `last_error` صفحهٔ فید است |
| محصولات دیروز ناموجود شدند | از فید حذف شده‌اند. اگر عمدی نبوده، فید را نگاه کنید |

## بعد چه بخوانید {#next}

- [گذاشتن رویداد](/docs/instrument) اگر هنوز `product_viewed` و `order_completed` نمی‌فرستید. بدون آن‌ها کاتالوگ پر هم تاریخچه‌ای برای پیشنهاد دادن ندارد.
- [فرهنگ‌نامهٔ رویدادها](/docs/event-dictionary#online-retail) برای فهرست رویدادهای یک فروشگاه اینترنتی.
- [سناریوها](/docs/journeys) برای ساختن سبد رهاشده.

---

# ساخت سگمنت و معنی دقیق هر شرط

> زبان شرط‌ها، همهٔ عملگرها، و تلهٔ اصلی: نام رویدادِ غلط ایراد نمی‌گیرد، فقط هیچ‌کس را برنمی‌گرداند.
> https://segmentic.net/docs/segments

یک سگمنت، یک درخت JSON از شرط‌هاست. پنل هیچ‌وقت SQL نمی‌فرستد؛ همین درخت را می‌فرستد و سرور آن را کامپایل می‌کند. برای شما یعنی هر کاری که پنل می‌کند از راه API هم شدنی است، و چند کار هست که فقط از راه API شدنی است: شرط روی ویژگی رویداد، تجمیع، و عضویت در سگمنت دیگر هیچ‌کدام دکمه‌ای در پنل ندارند.

این صفحه زبان شرط‌ها را کامل می‌گوید: شکل دقیق JSON، هر نوع شرط، هر عملگر، پنجره‌های زمانی، و فهرست چیزهایی که وجود ندارند. اگر ایجنت هوش مصنوعی هستید، بخش [تله](/docs/segments#trap) را قبل از نوشتن اولین فیلتر بخوانید.

## شکل کلی یک تعریف {#shape}

> نمودار: تبدیل ویژگی، رویداد و تعامل به مخاطب، کمپین و سناریو

بیرونی‌ترین لایه `Definition` است و دو فیلد دارد.

```json
{
  "version": 1,
  "root": { "kind": "group", "op": "and", "children": [] }
}
```

| فیلد | نوع | لازم | توضیح |
|---|---|---|---|
| `version` | عدد صحیح | نه | کامپایلر هیچ‌وقت آن را نمی‌خواند. پنل همیشه `1` می‌نویسد. اگر ننویسید، `0` ذخیره می‌شود و هیچ اتفاقی نمی‌افتد. |
| `root` | یک Node | بله | اگر نباشد، Node خالی با `kind` تهی می‌ماند و کامپایلر با `segment: unknown node kind: ""` رد می‌کند. |

در همه فراخوانی‌های HTTP این شیء یک لایه عمیق‌تر، داخل فیلدی به نام `definition` می‌نشیند:

```json
{"definition": {"version": 1, "root": {"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}}}}
```

`root` لازم نیست گروه باشد. یک شرط تنها هم ریشه معتبری است.

هر گره یک ساختار واحد است با فیلد `kind` که تعیین می‌کند کدام فیلدهای دیگر خوانده می‌شوند. ترتیب فیلدها روی سیم اهمیت دارد، چون اثر انگشت سگمنت (که تأیید کمپین با آن «همان مخاطب» را از «مخاطبی که بعد از تأیید عوض شده» تشخیص می‌دهد) از `sha256` بایت‌های JSON ساخته می‌شود.

| فیلد JSON | نوع | کدام `kind` آن را می‌خواند |
|---|---|---|
| `kind` | رشته | همه. یکی از `group`، `trait`، `event`، `segment`، `engagement`، `churn` |
| `op` | رشته | فقط `group` |
| `not` | بولین | فقط `group`، `engagement`، `churn` |
| `children` | آرایه Node | فقط `group` |
| `trait` | رشته | فقط `trait` |
| `compare_trait` | رشته | فقط `trait`. ویژگی دوم به‌جای `value`، در [مقایسه دو ویژگی](/docs/segments#trait-vs-trait) |
| `event` | رشته | فقط `event` |
| `negate` | بولین | فقط `event` |
| `count` | شیء | فقط `event` |
| `aggregate` | شیء | فقط `event` |
| `properties` | آرایه شیء | فقط `event` |
| `window` | شیء | فقط `event` |
| `segment_id` | عدد صحیح | فقط `segment` |
| `in_segment` | بولین | فقط `segment` |
| `band` | رشته | فقط `engagement` و `churn` |
| `metric` | رشته | فقط `engagement` |
| `operator` | رشته | `trait`، `engagement`، `churn` |
| `value` | شیء | `trait`، `engagement`، `churn` |

هر فیلدی که در ستون سوم نیامده باشد، روی آن نوع گره **بی‌سروصدا نادیده گرفته می‌شود**. این مهم‌ترین منبع سردرگمی است: `not: true` روی یک گره `trait` یا `event` یا `segment` هیچ کاری نمی‌کند و هیچ خطایی هم نمی‌دهد. برای نفی رویداد `negate` را بگذارید و برای نفی عضویت `in_segment: false`.

کل درخت به یک شرط روی جدول پرونده‌ها تبدیل می‌شود:

```sql
SELECT user_id FROM segmentic.profiles FINAL
WHERE tenant_id = {tenant:UInt32} AND (<شرط کامپایل‌شده>)
```

`FINAL` عمدی است و خواندن را گران می‌کند: بدون آن پرونده‌ای که دو بار به‌روزرسانی شده دو بار شمرده می‌شود، و اندازه غلط مخاطب اعتماد را همان لحظه از بین می‌برد.

## گروه: and، or و not {#group}

```json
{"kind": "group", "op": "or", "not": false, "children": [ ]}
```

- `op` فقط دو مقدار معنادار دارد: `and` و `or`. هر چیزی که **دقیق** برابر `or` نباشد، AND معنی می‌دهد. `"OR"` با حروف بزرگ هم AND است. هیچ اعتبارسنجی روی این فیلد نیست و هیچ خطایی نمی‌گیرید.
- `children` نباید خالی باشد. آرایه خالی یعنی خطای `segment: group has no children`.
- `not: true` کل گروه را در `NOT (...)` می‌پیچد.
- عمق تودرتویی: ریشه عمق صفر است و سقف عمق ۸ است، پس روی هم ۹ سطح. عمیق‌تر یعنی `segment: nesting too deep`.
- اندازه: سقف ۲۰۰ گره. بودجه هر گره را یک واحد و **هر عضو آرایه `properties` آن را هم یک واحد** حساب می‌کند، چون شرط روی ویژگی رویداد هم یک شرط است. بیشتر یعنی `segment: too many conditions`.

سازنده شرط در پنل کل درخت را ویرایش می‌کند: هر شرط عملگر خودش را دارد و با «و» یا «یا» به شرط بالای خودش وصل می‌شود، و انتخاب عملگری غیر از عملگر همان فهرست، آن دو شرط را در یک گروه می‌گذارد. پنل تا سه سطح گروه می‌سازد در حالی که کامپایلر تا نه سطح را می‌پذیرد؛ این سقف خوانایی است نه ایمنی، و تعریفی که از راه API عمیق‌تر ساخته شده باشد در پنل درست باز و درست ویرایش می‌شود، فقط عمیق‌تر نمی‌شود. گروهی که `not` دارد و شرط عضویت در سگمنت دیگر در پنل کنترلی ندارند: نشان داده می‌شوند و دست‌نخورده می‌مانند.

## شرط روی ویژگی پرونده {#trait}

```json
{"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}}
```

نام ویژگی trim می‌شود؛ تهی یا بلندتر از ۱۲۸ بایت یعنی `segment: invalid identifier`.

نام به همین ترتیب در چهار دسته گشته می‌شود و اولین جایی که پیدا شد برنده است: شش پرچم دسترس‌پذیری، هفت ویژگی عددی محاسبه‌شده، شانزده ستون رشته‌ای، و بعد `birthday`. نامی که در هیچ‌کدام نباشد ویژگی سفارشی است.

### ویژگی‌هایی که ستون واقعی‌اند {#trait-columns}

**شش پرچم دسترس‌پذیری.** `has_push`، `has_email`، `has_phone`، `push_opt_in`، `email_opt_in`، `sms_opt_in`.

اینها به `col = 1` یا `col = 0` تبدیل می‌شوند. منطق ساده و غافلگیرکننده است: مقدار پیش‌فرض «بله» است، اگر `value.bool` بفرستید همان می‌شود، و اگر عملگر `neq` باشد وارونه می‌شود. **هر عملگر دیگری، از `gt` تا `contains` تا `is_set`، دقیق مثل تساوی رفتار می‌کند.** پس `has_push` با عملگر `contains` هم قابل کامپایل است و همان کاری را می‌کند که `eq` می‌کرد.

**هفت ویژگی عددی محاسبه‌شده.**

| نام ویژگی | چه چیزی را می‌شمارد |
|---|---|
| `total_events` | تعداد کل رویدادهای این پرونده |
| `total_revenue` | مجموع مبلغ خریدها |
| `order_count` | تعداد سفارش |
| `days_since_last_seen` | `dateDiff('day', last_seen, now())` |
| `days_since_last_order` | `dateDiff('day', last_order_at, now())` |
| `days_until_birthday` | روز تا تولد بعدی: امروز صفر است، سه روز دیگر سه |
| `days_until_signup_anniversary` | همان حساب روی `first_seen` |

`days_until_birthday` یک سالگرد است، نه یک تاریخ. تاریخ تولد ذخیره‌شده یک تاریخ در گذشته است و مقایسه آن با یک پنجره زمانی بعد از سال اول هیچ‌کس را برنمی‌گرداند. ۲۹ فوریه در سال غیرکبیسه روی ۱ مارس می‌افتد. برای کسی که تاریخ تولد ندارد مقدار `NULL` است، پس هر مقایسه‌ای روی او نادرست می‌شود و ساده از مخاطب بیرون می‌ماند.

`days_until_signup_anniversary` قبل از مقایسه ماه و روز، `first_seen` را که یک DateTime است به روز کوتاه می‌کند. بدون این کار کسی که ساعت ۲۳:۳۰ ثبت‌نام کرده با کسی که فردا ۰۰:۳۰ ثبت‌نام کرده یک روز اختلاف پیدا می‌کرد.

هر هفت‌تا عددی‌اند، پس روی آن‌ها `is_set` یعنی `expr != 0` و `is_not_set` یعنی `expr = 0`. **یک ویژگی عددی که برابر صفر است، «ثبت نشده» خوانده می‌شود.**

**شانزده ستون رشته‌ای.** `user_id`، `email`، `phone`، `first_name`، `last_name`، `gender`، `city`، `region`، `country`، `language`، `timezone`، `device_type`، `os_name`، `app_version`، `push_provider`، `national_id`.

`national_id` تا مدتی در این فهرست نبود در حالی که تمام مدت ذخیره می‌شد، پس هر سگمنتی روی آن، برای هر مشتری، هیچ‌کس را برنمی‌گرداند و هیچ خطایی هم هیچ‌جا نبود. همین حادثه دلیل وجود این فهرست است.

### ویژگی‌های خودتان {#trait-custom}

هر نامی که در آن چهار دسته نباشد، یک ویژگی سفارشی است و در نگاشت‌های ClickHouse دنبالش می‌گردیم. **نام ناشناس خطا نیست**، چون مشتری‌ها مدام ویژگی خودشان را تعریف می‌کنند و رد کردن نام‌های ناشناس این قابلیت را بی‌مصرف می‌کرد. شاخه‌ای که انتخاب می‌شود به شکل مقایسه بستگی دارد.

| شکل مقایسه | SQL |
|---|---|
| `is_set` یا `is_not_set` | `has(mapKeys(traits), {p0:String})` و نفی آن |
| مقدار بولین با `eq` یا `neq` | `lower(traits[{p0:String}]) = {p1:String}` که `{p1}` رشته `true` یا `false` است |
| عملگر عددی، یا هر عملگری با مقدار عددی بجز `in` و `not_in` | `(mapContains(traits_num, {p0:String}) AND traits_num[{p0:String}] < {p1:Float64})` |
| هر چیز دیگر | `traits[{p0:String}]` با قواعد رشته‌ای بخش عملگرها |

سه نکته که هر کدام یک باگ واقعی روی داده مشتری بودند.

**حضور با کلید سنجیده می‌شود، نه با مقدار.** ویژگی ثبت‌نشده و ویژگی ثبت‌شده با مقدار تهی دو چیز متفاوت‌اند، و `is_set` روی ویژگی سفارشی تنها جایی است که این دو از هم جدا می‌شوند.

**بولین با متن مقایسه می‌شود.** ویژگی‌ای که به شکل JSON `true` فرستاده شده در `traits` رشته `"true"` و در `traits_num` عدد `1` ذخیره می‌شود. نگاشت متنی انتخاب شده چون نیمه‌ای است که همه پرونده‌ها دارند. جهت نفی هم عمدی است: `neq true` یعنی «می‌دانیم false است»، نه «نمی‌دانیم true است»، پس کسی که این ویژگی را هرگز نفرستاده در هیچ‌کدام از دو مخاطب نیست.

**عدد با `mapContains` محافظت می‌شود.** نگاشت ClickHouse برای کلید موجودنبود صفر برمی‌گرداند، پس بدون این محافظ «موجودی کمتر از ۱۰» هر پرونده‌ای را که هیچ موجودی ندارد برمی‌گرداند: این باگ اول بار با ۱۱۴۹۴۳ کاربر روی حسابی با حدود ۱۱۵۰۰۰ کاربر گزارش شد، عددی که مثل یک جواب واقعی به نظر می‌رسد. جهت شکست عمدی انتخاب شده است: شرط عددی روی ویژگی‌ای که هیچ‌کس ندارد حالا هیچ‌کس را انتخاب می‌کند نه همه را. مخاطب بی‌سروصدا خالی یعنی کمپینی که نمی‌رود و کسی متوجه می‌شود؛ مخاطب بی‌سروصدا همه یعنی کمپینی که رفته و برنمی‌گردد.

### birthday فقط به دو سؤال جواب می‌دهد {#trait-birthday}

`birthday` یک ستون `Nullable(Date)` است و **تنها** `is_set` و `is_not_set` را می‌پذیرد:

```json
{"kind": "trait", "trait": "birthday", "operator": "is_set"}
```

هر عملگر دیگری در زمان کامپایل رد می‌شود با این متن:

```text
segment: unsupported operator: birthday only answers is_set and is_not_set; for an anniversary use days_until_birthday
```

دلیلش این است که مقایسه غیرعددی `is_set` ستون را با رشته تهی می‌سنجد و ClickHouse این را روی یک Date با `Code: 38. Cannot parse date` رد می‌کند. برای سؤال سالگرد از `days_until_birthday` استفاده کنید که یک عدد است.

### مقایسه دو ویژگی با هم {#trait-vs-trait}

`compare_trait` به‌جای مقدار ثابت، ویژگی دومی را می‌گذارد، تا یک شرط بتواند دو عددی را بپرسد که خود پرونده هر دو را دارد:

```json
{"kind": "trait", "trait": "gc_referrals_total", "operator": "gt", "compare_trait": "gc_referrals_active"}
```

این یعنی «کسی را دعوت کرده که هنوز فعال نشده»، و هیچ عدد ثابتی این را نمی‌گوید: خط برای کسی که دو نفر را دعوت کرده روی دو است و برای کسی که چهل نفر را دعوت کرده روی چهل. روی همان حسابی که این نیاز از آن آمد، این مخاطب ۸۸۸ نفر است. نزدیک‌ترین چیزی که با یک آستانه ثابت به دست می‌آید ۴۰ نفر است.

وقتی `compare_trait` ست شده باشد، `value` خوانده نمی‌شود.

**شش عملگر.** `eq`، `neq`، `gt`، `gte`، `lt`، `lte`. هر چیز دیگری در زمان کامپایل رد می‌شود، چون `between` دو کران می‌خواهد و `in` یک فهرست، و یک ویژگی دوم هیچ‌کدام نیست، و `is_set` فقط درباره یک طرف می‌پرسد:

```text
segment: unsupported operator: comparing two traits takes eq, neq, gt, gte, lt or lte, got "between"
```

**هر دو طرف باید عدد باشند.** پرچم‌های دسترسی `UInt8` هستند و عدد حساب می‌شوند. ستون متنی در هر طرف با نام خودش رد می‌شود، و `birthday` هم همان رد قبلی خودش را نگه می‌دارد:

```text
segment: unsupported operator: city holds text, and comparing two traits compares numbers
```

دلیلش این است که وقتی هیچ طرفی مقدار ثابت نیست، چیزی نمانده که شکل مقایسه از آن خوانده شود. مسیر تک‌ویژگی بین `traits_num` و `traits` را از روی عملگر و مقداری که گرفته انتخاب می‌کند، و اینجا مقداری در کار نیست. عدد بودن مبهم نمی‌ماند، چون یک ویژگی عددی موقع ورود در هر دو نقشه نوشته می‌شود، پس `mapContains(traits_num, key)` جواب مطمئن «این ویژگی عدد است» را می‌دهد. برای جهت دیگر چنین آزمونی وجود ندارد، پس یک جفت متنی مجبور بود نقشه را حدس بزند، و حدسی که نقشه خالی را بخواند هیچ‌کس را انتخاب می‌کند و در عین حال شبیه جواب به نظر می‌رسد.

**پرونده‌ای که یکی از دو طرف را ندارد در مخاطب نیست.** هر ویژگی سفارشی نگهبان حضور خودش را همراه دارد، و `days_until_birthday` و شکل `days_since_` وقتی چیزی برای شمردن نباشد از قبل `NULL` می‌دهند. این همان جهت شکستی است که مسیر عددی ویژگی‌ها انتخاب کرده بود: مخاطبی که بی‌صدا خالی است کمپینی است که نمی‌رود و دیده می‌شود، و مخاطبی که بی‌صدا همه است کمپینی است که رفته.

| دو طرف | SQL |
|---|---|
| دو ویژگی سفارشی | `(mapContains(traits_num, {p0:String}) AND mapContains(traits_num, {p1:String}) AND traits_num[{p0:String}] > traits_num[{p1:String}])` |
| دو ستون واقعی | `(order_count > total_events)` |
| یکی از هر کدام | `(mapContains(traits_num, {p0:String}) AND order_count < traits_num[{p0:String}])` |

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

## شرط روی رویداد {#event}

```json
{
  "kind": "event",
  "event": "order_completed",
  "negate": false,
  "window": {"kind": "last", "amount": 30, "unit": "day"},
  "properties": [],
  "count": {"operator": "gte", "value": 3}
}
```

نام رویداد trim می‌شود؛ تهی یا بلندتر از ۱۲۸ بایت یعنی `segment: invalid identifier`.

هر شرط رویداد به یک زیرکوئری روی `segmentic.events` تبدیل می‌شود که همیشه این سه شرط پایه را دارد:

```sql
tenant_id = {tenant:UInt32}
AND name = {p0:String}
AND is_bot = 0
```

فیلتر ربات همیشگی است و **هیچ راهی برای خاموش کردنش نیست**. کسی نمی‌خواهد به یک خزنده پوش بفرستد.

بعد پنجره زمانی، بعد شرط‌های `properties`، و در آخر یک `HAVING` که یا از `aggregate` می‌آید یا از `count`. `GROUP BY user_id` فقط وقتی اضافه می‌شود که `HAVING` وجود داشته باشد.

بیرونی‌ترین لایه:

- `negate` غایب یا `false`: `user_id IN (زیرکوئری)`
- `negate: true`: `user_id NOT IN (زیرکوئری)`

`NOT IN` عمدی است: «انجام نداده» باید کسانی را که هیچ رویدادی ندارند هم شامل شود، و چون کوئری بیرونی از جدول پرونده‌ها می‌آید، `NOT IN` این را رایگان می‌دهد.

> [!warn]
> ترکیب `negate: true` با `count` را با دقت بخوانید. هر دو اعمال می‌شوند، پس نتیجه `user_id NOT IN (... HAVING count() >= 3)` است، یعنی «حداقل سه بار انجام نداده»، که کسی را که دو بار انجام داده **در مخاطب نگه می‌دارد**. کامپایلر هشدار نمی‌دهد. جمله توصیفی هم کمکی نمی‌کند: برای رویداد نفی‌شده، شمارش در جمله نوشته نمی‌شود و فقط «انجام نداده‌اند» را می‌خوانید.

### شرط روی ویژگی رویداد {#properties}

```json
{"property": "category", "operator": "eq", "value": {"type": "string", "str": "موبایل"}}
```

سه فیلد و بس: `property`، `operator`، `value`. نام ویژگی لازم است و سقف ۱۲۸ بایت دارد.

ترتیب تصمیم‌گیری:

1. `revenue` مستقیم روی ستون ارتقایافته `revenue` می‌نشیند و عددی حساب می‌شود.
2. عملگر عددی، یا هر عملگری با مقدار عددی بجز `in` و `not_in`، به `props_num[{key:String}]` می‌رود.
3. مقدار بولین با `eq` یا `neq` روی `props_str[key]` با رشته `true` یا `false` مقایسه می‌شود.
4. `is_set` و `is_not_set` به `has(mapKeys(props_str), {key:String})` تبدیل می‌شوند.
5. باقی همه روی `props_str[{key:String}]` با قواعد رشته‌ای.

دو چیز که باید بدانید. اول اینکه اعضای `properties` همیشه با AND به هم وصل می‌شوند؛ **هیچ راهی برای OR بین دو شرط ویژگی یک رویداد وجود ندارد**. دوم اینکه برخلاف مسیر ویژگی پرونده، اینجا **محافظ `mapContains` نیست**. `props_num` برای کلیدی که رویداد نداشته صفر برمی‌گرداند، پس شرط `"revenue_share" lt 10` رویدادهایی را هم می‌گیرد که این ویژگی را هرگز نداشته‌اند.

### تعداد دفعات {#count}

```json
{"count": {"operator": "gte", "value": 3}}
```

| فیلد | نوع | توضیح |
|---|---|---|
| `operator` | رشته | یکی از `eq`، `neq`، `gt`، `gte`، `lt`، `lte`، `between` |
| `value` | عدد | حد یا کران پایین |
| `value2` | عدد | فقط برای `between`، کران بالا |

به `HAVING count() <op> {value:Float64}` تبدیل می‌شود، و برای `between` به `HAVING count() BETWEEN {a:Float64} AND {b:Float64}`. مقدار به شکل `Float64` بایند می‌شود، پس تعداد اعشاری بدون هیچ اعتراضی پذیرفته می‌شود و کاری که انتظار دارید نمی‌کند.

`count()` سطرهای رویداد را می‌شمارد. **شمارش مقدارهای یکتا وجود ندارد.**

پنل فقط `gte`، `gt`، `lte`، `lt` و `eq` را نشان می‌دهد؛ `between` فقط از API قابل نوشتن است.

### تجمیع روی یک ویژگی عددی {#aggregate}

```json
{"aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}}
```

| فیلد | نوع | توضیح |
|---|---|---|
| `function` | رشته | فقط `sum`، `avg`، `min`، `max`. بزرگ و کوچک حروف مهم نیست. |
| `property` | رشته | `revenue` یا هر کلید عددی رویداد |
| `operator` | رشته | همان شش عملگر عددی، به‌علاوه `between` |
| `value` | عدد | حد |
| `value2` | عدد | فقط برای `between` |

نامی خارج از آن چهار تابع یعنی `segment: invalid identifier`. **تابع `count` در اینجا وجود ندارد**؛ برای شمردن از `count` بخش قبل استفاده کنید. `first` و `last` هم وجود ندارند، پس هیچ راهی برای شرط گذاشتن روی مقدار ویژگی در آخرین رخداد یک رویداد نیست.

اینجا هم مثل `properties` محافظ `mapContains` نیست، پس `sum` روی ویژگی‌ای که بیشتر رویدادها ندارند بی‌سروصدا صفر جمع می‌زند.

**`aggregate` بی‌سروصدا بر `count` مقدم است.** اگر هر دو را بفرستید، `count` نادیده گرفته می‌شود.

پنجره‌ای که تجمیع روی آن اجرا می‌شود، همان `window` خود گره رویداد است. فیلد جداگانه‌ای برای دوره تجمیع وجود ندارد.

## عضویت در سگمنت دیگر {#segment-membership}

```json
{"kind": "segment", "segment_id": 1234, "in_segment": true}
```

به این تبدیل می‌شود:

```sql
user_id IN (SELECT user_id FROM segmentic.segment_members
            WHERE tenant_id = {tenant:UInt32} AND segment_id = {p0:UInt64})
```

`segment_id` باید باشد و غیرصفر باشد، وگرنه `segment: invalid identifier: segment_id must be set`.

دو تله اینجاست و هر دو بی‌سروصدا هستند.

**`in_segment` پیش‌فرض `false` است و `false` یعنی `NOT IN`.** ننوشتن این فیلد یعنی «عضو نیست»، نه «عضو هست».

**این شرط فقط جدول فهرست‌های ثابت را می‌خواند.** `segment_members` جایی است که عضویت سگمنت‌های `static` نوشته می‌شود. یک سگمنت `dynamic` هیچ سطری آنجا ندارد، پس اشاره به آن یک مجموعه تهی می‌دهد. این خطا نیست، فقط صفر است.

## تعامل {#engagement}

امتیازهای تعامل هر شب روی کل تاریخچه پیام‌ها محاسبه می‌شوند و در جدول خودشان زندگی می‌کنند، چون این یک اسکن است که هیچ نوشتن روی پرونده نمی‌تواند آن را حمل کند. به همین دلیل یک `kind` جداگانه است و نه یک ویژگی عددی.

دو شکل دارد که به همین ترتیب بررسی می‌شوند.

**گروه.** `band` را بگذارید. مقدارهای مجاز: `engaged`، `passive`، `dormant`، `lost`، `new`.

```json
{"kind": "engagement", "band": "dormant"}
```

**سنجه.** `metric` را بگذارید. مقدارهای مجاز: `score`، `ignored_streak`، `open_rate`، `click_rate`، `days_since_engaged`.

```json
{"kind": "engagement", "metric": "ignored_streak", "operator": "gte", "value": {"type": "number", "num": 10}}
```

عملگر **باید عددی باشد**: فقط `gt`، `gte`، `lt`، `lte`، `between`. توجه کنید که `eq` و `neq` در این تعریف عددی نیستند و رد می‌شوند، با متن `segment: unsupported operator: engagement needs a numeric operator, got "eq"`. یک نرخ یا یک زنجیره بی‌پاسخ، «شامل» معناداری ندارد.

اگر نه `band` بدهید و نه `metric`: `segment: invalid identifier: engagement needs a band or a metric`.

نام گروه و نام سنجه **هر دو اعتبارسنجی می‌شوند**، برخلاف نام رویداد. دلیلش صریح در کد نوشته شده: غلط املایی بی‌سروصدا هیچ‌کس را برنمی‌گرداند، و سگمنتی که هیچ‌کس را برنمی‌گرداند دقیق شبیه سگمنتی است که مخاطبش ساکت شده، و همین دومی چیزی است که این قابلیت برای تشخیصش ساخته شده.

`not: true` روی این گره کار می‌کند و به `NOT IN` تبدیل می‌شود، که درست هم هست: کسی که کار شبانه هرگز امتیازش نداده، به‌طور قطع «فعال اثبات‌شده» نیست.

زیرکوئری با `FINAL` اجرا می‌شود، چون جدول یک ReplacingMergeTree است که کار شبانه بازنویسی‌اش می‌کند و بدون `FINAL` کسی که دو شب پشت‌سرهم امتیاز گرفته روی سطر قدیمی هم می‌افتد.

## ریسک ریزش {#churn}

```json
{"kind": "churn", "band": "high"}
```

**گروه.** `band` یکی از `high`، `medium`، `low`، `unknown`.

**آستانه.** `operator` را بگذارید و عددی باشد. مقایسه روی ستون `probability` انجام می‌شود که **درصد صحیح** است، نه کسر. یعنی «ریسک ریزش بیشتر از ۷۰» را با `70` می‌نویسید نه با `0.7`.

```json
{"kind": "churn", "operator": "gt", "value": {"type": "number", "num": 70}}
```

اگر هیچ‌کدام: `segment: invalid identifier: churn needs a band or a threshold`. اگر عملگر عددی نباشد: `segment: unsupported operator: churn risk needs a numeric operator, got "eq"`.

دقت کنید که تقارن با تعامل برقرار نیست: churn شکل آستانه را وقتی انتخاب می‌کند که `operator` تهی نباشد، در حالی که engagement شکل سنجه را وقتی انتخاب می‌کند که `metric` تهی نباشد. گره churn که هم `band` و هم `operator` دارد، `band` را برمی‌دارد.

کسی که پیش‌بینی ندارد به عمد نه در هیچ گروهی است و نه بالای هیچ آستانه‌ای، پس از هر دو شکل بیرون می‌افتد. کسی که مدل هرگز ندیده «کم‌ریسک» نیست؛ نامشخص است، و کمپین بازگردانی‌ای که این دو را یکی بگیرد بودجه‌اش را خرج کسانی می‌کند که هیچ‌کس نگاهشان نکرده.

`not: true` کار می‌کند و `NOT IN` می‌دهد. زیرکوئری هم `FINAL` دارد.

## عملگرها {#operators}

این پانزده رشته همه عملگرهای موجودند. رشته را دقیق همین‌طور بنویسید.

| عملگر | نوع مقدار | معنی | SQL تولیدشده |
|---|---|---|---|
| `eq` | رشته، عدد، بولین | برابر است | `lower(expr) = {p:String}` یا `expr = {p:Float64}` |
| `neq` | رشته، عدد، بولین | برابر نیست | `lower(expr) != {p:String}` یا `expr != {p:Float64}` |
| `contains` | رشته | زیررشته، بدون حساسیت به بزرگی حروف | `positionCaseInsensitiveUTF8(expr, {p:String}) > 0` |
| `not_contains` | رشته | زیررشته نیست | `positionCaseInsensitiveUTF8(expr, {p:String}) = 0` |
| `starts_with` | رشته | با این شروع می‌شود | `startsWith(lower(expr), {p:String})` |
| `ends_with` | رشته | به این ختم می‌شود | `endsWith(lower(expr), {p:String})` |
| `gt` | عدد | بیشتر از | `expr > {p:Float64}` |
| `gte` | عدد | حداقل | `expr >= {p:Float64}` |
| `lt` | عدد | کمتر از | `expr < {p:Float64}` |
| `lte` | عدد | حداکثر | `expr <= {p:Float64}` |
| `between` | عدد، هم `num` و هم `num2` | بازه بسته از دو طرف | `expr BETWEEN {a:Float64} AND {b:Float64}` |
| `in` | فهرست رشته | یکی از این‌ها | `has({p:Array(String)}, lower(expr))` |
| `not_in` | فهرست رشته | هیچ‌کدام از این‌ها | `NOT has({p:Array(String)}, lower(expr))` |
| `is_set` | بدون مقدار | ثبت شده است | بسته به نوع ستون، پایین را ببینید |
| `is_not_set` | بدون مقدار | ثبت نشده است | بسته به نوع ستون، پایین را ببینید |

هر عملگری بجز `is_set` و `is_not_set` به `value` نیاز دارد. نبودش یعنی `segment: operator requires a value`.

`is_set` سه معنی متفاوت دارد و این تفاوت واقعی است، نه ظرافت:

| کجا | `is_set` | `is_not_set` |
|---|---|---|
| ستون عددی | `expr != 0` | `expr = 0` |
| ستون رشته‌ای | `expr != ''` | `expr = ''` |
| ویژگی سفارشی | `has(mapKeys(traits), key)` | نفی همان |
| `birthday` | `birthday IS NOT NULL` | `birthday IS NULL` |

هر مقایسه رشته‌ای، هر دو طرف را تا می‌کند تا فیلتری که با «ی» فارسی نوشته شده، پرونده‌ای را که با یای عربی (`U+064A`) ذخیره شده هم بگیرد. این شایع‌ترین دلیلی است که مخاطب دست‌ساز کوتاه برمی‌گردد. `تهراني` و `تهرانی` هر دو به یک مقدار بایند می‌شوند. همین تا کردن با کامپایلر تحلیل‌ها مشترک است، چون داشبوردی که روی «تهران» فیلتر شده باید دقیق همان آدم‌هایی را بشمارد که این سگمنت می‌شمارد.

کل فهرست `in` به شکل **یک** پارامتر `Array(String)` می‌رود، پس فهرست هزار شهری هم یک جای‌نگهدار است.

### مقدارها {#values}

```json
{"type": "number", "num": 1000, "num2": 5000}
```

| `type` | کدام فیلد بار را می‌برد |
|---|---|
| `string` | `str` |
| `number` | `num` و برای کران بالای `between` هم `num2` |
| `bool` | `bool` |
| `list` | `list`، آرایه‌ای از رشته |
| `date` | `date` و `date2` |

`type` هیچ‌وقت با عملگر تطبیق داده نمی‌شود. مقدار `{"type": "string", "str": "۵"}` با عملگر `gt` باعث می‌شود کامپایلر `num` را بخواند که صفر است، و شرط `expr > 0` می‌شود. خطایی نمی‌گیرید.

> [!danger]
> `type: "date"` روی سیم پذیرفته می‌شود و **کامپایلر هرگز آن را نمی‌خواند**. تابع مقایسه فقط `num`، `num2`، `str` و `list` را می‌خواند. یک مقدار تاریخ با عملگر رشته‌ای در عمل با رشته تهی مقایسه می‌شود. مقایسه تاریخ روی ویژگی پرونده پیاده‌سازی نشده است. برای سؤال سالگرد از `days_until_birthday` و `days_until_signup_anniversary` استفاده کنید.

فهرست حداکثر ۱۰۰۰ عضو دارد؛ بیشتر یعنی `segment: list has too many values`. فهرست تهی با `in` یا `not_in` یعنی `segment: operator requires a value`.

## پنجره زمانی {#windows}

```json
{"kind": "last", "amount": 30, "unit": "day"}
```

**`window` فقط روی گره `event` خوانده می‌شود.** روی `trait`، `segment`، `engagement` و `churn` بی‌سروصدا نادیده گرفته می‌شود.

| `kind` | فیلدهای لازم | شرط تولیدشده |
|---|---|---|
| `all_time` یا رشته تهی | هیچ | هیچ شرطی روی `event_time` گذاشته نمی‌شود |
| `last` | `amount`، `unit` | `event_time >= now() - INTERVAL {p:UInt32} <UNIT>` |
| `between` | `from`، `to` | `event_time BETWEEN {p:DateTime64(3)} AND {p:DateTime64(3)}` |
| `after` | `from` | `event_time >= {p:DateTime64(3)}` |
| `before` | `to` | `event_time < {p:DateTime64(3)}` |

نبودن کل شیء `window` همان `all_time` است. هر `kind` دیگری یعنی `segment: invalid time window: kind "..."`.

**پنجره نسبی.** `unit` یکی از `minute`، `hour`، `day`، `week`، `month` است و بزرگی حروف مهم نیست. واحد تنها بخشی از کوئری است که نمی‌تواند پارامتر بایندشده باشد، و فهرست مجاز دقیق به همین دلیل وجود دارد. `amount` باید بین ۱ و ۱۰۰۰۰ باشد.

`INTERVAL n MONTH` در ClickHouse یک ماه تقویمی است. ولی تابعی که زمین‌بازی زمانی یک تعریف را برای زمان‌بند حساب می‌کند، ماه را ۳۰ روز می‌گیرد. یعنی برای پنجره‌های ماهانه، پیش‌بررسی زمان‌بند و کوئری واقعی کمی با هم اختلاف دارند.

**پنجره مطلق.** `from` و `to` مهرزمان `RFC 3339` هستند.

```json
{"kind": "between", "from": "2026-03-21T00:00:00Z", "to": "2026-06-21T00:00:00Z"}
```

`between` هر دو کران را می‌خواهد و `to` نباید قبل از `from` باشد، وگرنه `segment: invalid time window: between needs from and to` یا `segment: invalid time window: to is before from`. `after` فقط `from` می‌خواهد و `before` فقط `to`. دقت کنید که `after` شامل خود لحظه است (`>=`) و `before` نیست (`<`).

**همه‌چیز UTC است.** فیلد منطقه زمانی روی پنجره وجود ندارد، منطقه زمانی مشتری روی پنجره اعمال نمی‌شود، و **تاریخ جلالی روی سیم فرستاده نمی‌شود**. این تصمیم صریح است: پنل تاریخ را جلالی نشان می‌دهد و همیشه لحظه UTC می‌فرستد.

جلالی فقط در جمله توصیفی ظاهر می‌شود. پنجره `{"kind": "after", "from": "2026-03-21T00:00:00Z"}` در فارسی این‌طور خوانده می‌شود:

```text
پس از ۱ فروردین ۱۴۰۵
```

و همان لحظه در انگلیسی `21 March 2026` است.

**پنل `between` را می‌نویسد، `before` و `after` را نه.** کنترل پنجره همان فهرست ۱، ۷، ۱۴، ۳۰، ۹۰، ۱۸۰ و ۳۶۵ روز به‌علاوه «همه زمان‌ها» را دارد، و کنار آن‌ها گزینه «بین دو تاریخ» که تقویم جلالی را باز می‌کند و `from` و `to` را می‌نویسد. کوهورت، یعنی «کسانی که اولین بار X را بین این دو روز انجام دادند»، تنها چیزی است که با پنجره نسبی گفته نمی‌شود و به همین دلیل این گزینه وجود دارد.

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

`before` و `after` هنوز فقط از راه API نوشته می‌شوند.

## تله: نام رویداد غلط، کامپایل تمیز و مخاطب صفر {#trap}

> [!warn]
> نام رویداد در برابر هیچ فهرستی بررسی نمی‌شود. کامپایلر فقط چک می‌کند که تهی نباشد و از ۱۲۸ بایت بلندتر نباشد، بعد آن را به‌عنوان پارامتر بایند می‌کند. `order_completd` یک SQL کامل و معتبر تولید می‌کند که صفر سطر برمی‌گرداند، و این از یک مخاطب واقعی صفر **قابل تشخیص نیست**.
>
> همین تله برای نام ویژگی پرونده و نام ویژگی رویداد هم برقرار است، در `properties` و در `aggregate`.

چرا این‌طور طراحی شده: مشتری‌ها مدام ویژگی و رویداد خودشان را تعریف می‌کنند و رد کردن نام‌های ناشناس این قابلیت را بی‌مصرف می‌کرد. سابقه‌اش هم هست: ویژگی `national_id` تمام مدت ذخیره می‌شد ولی از فهرست ستون‌ها جا افتاده بود، پس هر سگمنتی روی آن، برای هر مشتری، هیچ‌کس را برنمی‌گرداند و هیچ خطایی هیچ‌جا نبود.

راه چاره این است که نام‌ها را نپرسید، بخوانید.

```bash
curl https://api.segmentic.net/v1/schema/events \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "events": [
    {"name": "order_completed", "volume": 812443, "prop_keys": ["revenue", "category", "coupon"], "last_seen": "2026-08-06"},
    {"name": "product_viewed", "volume": 4192010, "prop_keys": ["sku", "category"], "last_seen": "2026-08-07"}
  ]
}
```

فهرست به ترتیب حجم مرتب است. `last_seen` مفیدترین ستون این پاسخ است: رویدادی با حجم بزرگ و `last_seen` سه هفته پیش یعنی یکپارچه‌سازی‌ای که خراب شده، و هیچ عدد دیگری این را نمی‌گوید. حجم به‌تنهایی تا یک ماه بعد هم سالم به نظر می‌رسد، چون پنجره‌اش ۹۰ روز است.

برای ویژگی‌های پرونده:

```bash
curl https://api.segmentic.net/v1/schema/traits \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "traits": ["city", "loyalty_tier", "gc_key_balance"],
  "schema": [
    {"name": "city", "kind": "string", "users": 114233},
    {"name": "loyalty_tier", "kind": "string", "users": 40112},
    {"name": "gc_key_balance", "kind": "number", "users": 98004}
  ]
}
```

`kind` می‌گوید ویژگی در کدام نگاشت زندگی می‌کند، و همان چیزی است که تعیین می‌کند «بیشتر از ۵۰۰۰۰۰۰» و «برابر ۵۰۰۰۰۰۰» روی چه ستونی کامپایل می‌شوند. `users` تعداد پرونده‌هایی است که این ویژگی را دارند؛ ویژگی‌ای که سه نفر دارند به احتمال زیاد آن چیزی نیست که فکر می‌کنید.

هر دو مسیر مجوز `event.read` می‌خواهند.

بعد از ساختن فیلتر، آن را با `POST /v1/audiences/validate` بخوانید و جمله فارسی برگشتی را با آنچه در سرتان بود مقایسه کنید. کسی که به‌جای مشهد «تهران» می‌خواند، باگش را قبل از خرج کردن یک کوئری پیدا کرده است.

## هشت مثال کامل {#examples}

هر مثال، JSON کامل است به‌علاوه جمله‌ای که سرور برمی‌گرداند. جمله فارسی چیزی است که `POST /v1/audiences/validate` در فیلد `description_fa` می‌دهد. جمله انگلیسی چیزی است که پنل در حالت انگلیسی نشان می‌دهد؛ **API عمومی همیشه فارسی جواب می‌دهد**، چون میان‌افزار زبان روی آن سوار نیست و پیش‌فرض فارسی است.

مقدارها ترجمه نمی‌شوند. هر رشته‌ای که در فیلتر بنویسید، همان‌طور که هست در جمله برمی‌گردد.

### سبد رها شده {#example-cart}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "group",
      "op": "and",
      "children": [
        {
          "kind": "event",
          "event": "product_added_to_cart",
          "count": {"operator": "gte", "value": 1},
          "window": {"kind": "last", "amount": 7, "unit": "day"}
        },
        {
          "kind": "event",
          "event": "order_completed",
          "negate": true,
          "count": {"operator": "gte", "value": 1},
          "window": {"kind": "last", "amount": 7, "unit": "day"}
        }
      ]
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که در ۷ روز گذشته «افزودن به سبد» را حداقل یک بار انجام داده‌اند و در ۷ روز گذشته «خرید» انجام نداده‌اند"}
```

انگلیسی، از پنل:

```text
Users who in the last 7 days did “Added to cart” at least once and in the last 7 days did not do “Purchase”
```

### خریدار تهرانی که اپ را باز نکرده {#example-tehran}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "group",
      "op": "and",
      "children": [
        {
          "kind": "event",
          "event": "order_completed",
          "count": {"operator": "gte", "value": 3},
          "window": {"kind": "last", "amount": 30, "unit": "day"}
        },
        {"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}},
        {"kind": "event", "event": "app_opened", "negate": true}
      ]
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که در ۳۰ روز گذشته «خرید» را حداقل ۳ بار انجام داده‌اند و شهر آن‌ها «تهران» است و «باز کردن اپ» انجام نداده‌اند"}
```

رویداد سوم پنجره ندارد، پس «هیچ‌وقت اپ را باز نکرده‌اند» معنی می‌دهد، نه «در سی روز گذشته باز نکرده‌اند».

### مجموع خرید در نود روز {#example-spend}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "event",
      "event": "order_completed",
      "window": {"kind": "last", "amount": 90, "unit": "day"},
      "aggregate": {"function": "sum", "property": "revenue", "operator": "gte", "value": 2000000}
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که در ۹۰ روز گذشته مجموع مبلغ «خرید» آن‌ها حداقل ۲٬۰۰۰٬۰۰۰ است"}
```

```text
Users who in the last 90 days have a total amount for “Purchase” that is at least 2,000,000
```

اینجا ریشه یک گره رویداد است، نه گروه. این معتبر است.

عددها در جمله فارسی با ارقام فارسی و جداکننده هزارگان عربی (`U+066C`) نوشته می‌شوند، چون در ایران مبلغ این‌طور خوانده می‌شود.

### تهرانی‌های قابل دسترسی، با گروه تودرتو {#example-reachable}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "group",
      "op": "and",
      "children": [
        {"kind": "trait", "trait": "city", "operator": "eq", "value": {"type": "string", "str": "تهران"}},
        {
          "kind": "group",
          "op": "or",
          "children": [
            {"kind": "trait", "trait": "has_push", "operator": "eq", "value": {"type": "bool", "bool": true}},
            {"kind": "trait", "trait": "has_email", "operator": "eq", "value": {"type": "bool", "bool": true}}
          ]
        }
      ]
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که شهر آن‌ها «تهران» است و (قابلیت دریافت پوش دارند یا داشتن ایمیل دارند)"}
```

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

### عضو یک فهرست ثابت {#example-membership}

```json
{
  "definition": {
    "version": 1,
    "root": {"kind": "segment", "segment_id": 1234, "in_segment": true}
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که عضو سگمنت شماره ۱٬۲۳۴ هستند"}
```

اگر `"in_segment": true` را بردارید، معنی وارونه می‌شود:

```text
کاربرانی که عضو سگمنت شماره ۱٬۲۳۴ نیستند
```

این تنها فیلدی در کل این زبان است که نبودنش شرط را وارونه می‌کند.

### خرید بالای پانصد هزار در یک دسته {#example-property}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "event",
      "event": "order_completed",
      "window": {"kind": "last", "amount": 7, "unit": "day"},
      "properties": [
        {"property": "revenue", "operator": "gt", "value": {"type": "number", "num": 500000}},
        {"property": "category", "operator": "eq", "value": {"type": "string", "str": "موبایل"}}
      ]
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که در ۷ روز گذشته «خرید» انجام داده‌اند که مبلغ آن بیشتر از ۵۰۰٬۰۰۰ باشد و category آن «موبایل» باشد"}
```

`revenue` تنها ویژگی رویداد است که نام فارسی دارد و «مبلغ» خوانده می‌شود. هر کلید دیگری خام در وسط جمله فارسی می‌نشیند، چون برای فیلدی که مشتری اختراع کرده ترجمه‌ای وجود ندارد و ساختن ترجمه از نشان دادن چیزی که خودش تایپ کرده بدتر است.

### در آستانه ریزش {#example-churn}

```json
{"definition": {"version": 1, "root": {"kind": "churn", "band": "high"}}}
```

```json
{"valid": true, "description_fa": "کاربرانی که در گروه «ریسک ریزش بالا» هستند"}
```

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

### ده پیام پشت‌سرهم بی‌پاسخ {#example-engagement}

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "engagement",
      "metric": "ignored_streak",
      "operator": "gte",
      "value": {"type": "number", "num": 10}
    }
  }
}
```

```json
{"valid": true, "description_fa": "کاربرانی که پیام‌های بی‌پاسخ پشت‌سرهم آن‌ها حداقل ۱۰ است"}
```

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

## سنجیدن مخاطب: کدام فراخوانی چه چیزی می‌دهد {#sizing}

شش مسیر اینجا هست و فقط دوتای اول از اینترنت قابل دسترسی‌اند.

| مسیر | میزبان | مجوز | چه می‌دهد | هزینه |
|---|---|---|---|---|
| `POST /v1/audiences/validate` | `api.segmentic.net` | `segment.read` | معتبر بودن و جمله فارسی | ۱ واحد |
| `POST /v1/audiences/count` | `api.segmentic.net` | `segment.read` | شمارش دقیق | ۲۵ واحد |
| `POST /v1/segments/estimate` | فقط پنل | `segment.read` | تخمین نمونه‌گیری‌شده | ندارد |
| `POST /v1/segments/preview` | فقط پنل | `profile.read` | چند پرونده واقعی | ندارد |
| `POST /v1/segments/identifier-preview` | فقط پنل | `profile.read` | تعداد تطبیق و حداکثر ۱۰۰ پرونده برای شناسه، ایمیل یا شماره | ندارد |
| `POST /v1/segments/describe` | فقط پنل | `segment.read` | فقط جمله | ندارد |

«فقط پنل» یعنی این مسیرها روی صفحه کنترل داشبورد ثبت شده‌اند، و آن پورت به عمد از اینترنت مسیریابی نمی‌شود. پروکسی فقط میزبان عمومی را به شنونده دوم API می‌برد. پس نمی‌توانید `POST /v1/segments/preview` را از سرور خودتان صدا بزنید؛ آن دکمه پیش‌نمایش داخل پنل است.

پیش‌نمایش شناسه‌ها بدنه‌ای مثل `{"identifiers":["09123456789","user_42"],"limit":100}` می‌گیرد. حداکثر پنجاه هزار ورودی را در یک کوئری محدود به همان tenant تطبیق می‌دهد و `count`، `users`، `unmatched` و در صورت بریده شدن ورودی `truncated` را برمی‌گرداند. این مسیر چیزی در عضویت سگمنت نمی‌نویسد.

پنج مسیر دیگر بدنه یکسانی می‌خواهند:

```json
{"definition": { }, "limit": 10}
```

`limit` را فقط `preview` می‌خواند.

### validate {#validate-endpoint}

```bash
curl -X POST https://api.segmentic.net/v1/audiences/validate \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
```

```json
{"valid": true, "description_fa": "کاربرانی که شهر آن‌ها «تهران» است"}
```

فیلتر نامعتبر با کد وضعیت **۴۲۲** و پاکت خطای API عمومی برمی‌گردد:

```json
{"error": {"code": "filter_invalid", "message": "segment: group has no children"}}
```

این عمدی است و با نسخه داخل پنل فرق دارد: پنل به فیلتر نامعتبر ۲۰۰ با `valid: false` جواب می‌دهد، که برای فرمی که کاربر همان لحظه در آن تایپ می‌کند درست است و برای یکپارچه‌سازی‌ای که مدیریت خطایش روی کد وضعیت شاخه می‌زند غلط.

هیچ دیتابیسی لمس نمی‌شود. این ارزان‌ترین راه برای اطمینان از اینکه فیلترتان همان چیزی است که فکر می‌کنید.

### count {#count-endpoint}

```bash
curl -X POST https://api.segmentic.net/v1/audiences/count \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
```

```json
{"count": 114233, "approximate": false, "description": "کاربرانی که شهر آن‌ها «تهران» است", "took_ms": 812}
```

اینجا سه چیز غافلگیرکننده هست و هر سه از یک ریشه می‌آیند: این مسیر همان handler پنل را دوباره به کار می‌گیرد.

- نام فیلد `description` است، نه `description_fa`، ولی محتوایش همیشه فارسی است.
- خطاهایش پاکت **مسطح** پنل را دارند، نه پاکت API عمومی. فیلتر نامعتبر یعنی `400 {"error": "segment: ..."}` و خرابی انبار داده یعنی `503 {"error": "count unavailable"}`. این با وعده «یک شکل خطا در همه‌جا» روی API عمومی نمی‌خواند.
- `approximate` همیشه `false` است.

هزینه‌اش ۲۵ واحد از بودجه است، چون یک اسکن کامل `FINAL` روی پرونده‌های شماست به‌علاوه هر زیرکوئری. مهلتش ۳۰ ثانیه است.

هیچ محدودکننده هم‌زمانی روی این مسیر وجود ندارد و هیچ ردی از «پنجره بی‌کران» گرفته نمی‌شود: فیلتری با شرط رویدادی بدون `window` کامپایل و اجرا می‌شود.

### estimate {#estimate-endpoint}

شمارنده زنده زیر سازنده سگمنت در پنل. با هش کردن شناسه کاربر نمونه می‌گیرد و یکی از هر ۱۰۰ را می‌خواند، بعد عدد را ضرب می‌کند.

```json
{"count": 2400000, "approximate": true, "sample_rate": 100, "description": "کاربرانی که شهر آن‌ها «تهران» است", "took_ms": 41}
```

مهلتش ۳ ثانیه است و در صورت سررسید `503 {"error": "estimate unavailable"}` می‌دهد؛ منتظر نمی‌ماند.

یک بازگشت به شمارش دقیق دارد: اگر عدد نمونه‌گیری‌شده زیر ۳۰۰۰ باشد (یعنی زیر ۳۰ سطر واقعی در نمونه) کوئری دقیق دوباره اجرا می‌شود و پاسخ با `approximate: false` و بدون `sample_rate` برمی‌گردد. دلیلش این است که مخاطب ۴۰ نفره با نمونه یک در صد صفر خوانده می‌شود، و صفر غلط بدتر از عدد تقریبی است.

### preview {#preview-endpoint}

مجوزش `profile.read` است نه `segment.read`، چون این مسیر نام و شماره موبایل و شهر آدم‌های واقعی را برمی‌گرداند.

```json
{"users": [{"user_id": "u_1", "email": "ali@example.ir", "phone": "+989120000000", "first_name": "علی", "city": "تهران", "last_seen": "2026-08-01T09:00:00Z"}]}
```

`limit` پیش‌فرض ۱۰ است و سقف ۱۰۰. سقف عمدی است، نه فقط پیش‌فرض: احترام گذاشتن به `limit` بدون سقف، «پیش‌نمایش» را به خروجی انبوه فهرست مشتری تبدیل می‌کند که هر کسی با `profile.read` به آن می‌رسد، و در لاگ ممیزی از نگاه انداختن به ده سطر قابل تشخیص نیست. بردن داده بیرون از ساختمان مجوز `data.export` است که جداست.

مرتب‌سازی `last_seen DESC` است، پس تازه‌ترین تطابق‌ها را می‌بینید نه نمونه تصادفی. فیلدهای تماس **پوشانده نمی‌شوند**.

### describe {#describe-endpoint}

فقط جمله را برمی‌گرداند و به هیچ دیتابیسی دست نمی‌زند، به همین دلیل سازنده سگمنت می‌تواند با هر ضربه کلید صدایش بزند.

```json
{"description": "کاربرانی که شهر آن‌ها «تهران» است"}
```

زبانش از هدر `Accept-Language` می‌آید، چون میان‌افزار زبان روی mux پنل سوار است.

**این مسیر اعتبارسنجی نمی‌کند.** تعریفی که کامپایل نمی‌شود هم جمله می‌گیرد، و تعریف تهی جمله `همه کاربران` می‌گیرد. یعنی describe و validate درباره تعریف تهی با هم اختلاف دارند: describe می‌گوید «همه کاربران» و validate آن را رد می‌کند.

## سگمنت ذخیره‌شده: ساخت، ویرایش، حذف {#saved}

سگمنت ذخیره‌شده یک سطر در پستگرس است با یک نام یکتا در هر حساب.

```json
{
  "id": 11,
  "name": "خریداران تهران",
  "kind": "dynamic",
  "definition": {"version": 1, "root": { }},
  "description_fa": "کاربرانی که شهر آن‌ها «تهران» است",
  "last_size": 0,
  "updated_at": "2026-08-07T11:20:00Z"
}
```

`description_fa` همیشه سمت سرور محاسبه و در زمان ذخیره کش می‌شود؛ هر چیزی که در این فیلد بفرستید دور ریخته می‌شود.

سه نوع وجود دارد:

- `dynamic`، پیش‌فرض. هیچ‌چیز مادی نمی‌شود. تعریف هر بار که کسی می‌شمارد یا کمپینی صفحه‌بندی می‌کند، تازه اجرا می‌شود.
- `static`. عضویت، سطرهایی است که کسی وارد کرده. تعریفش کامپایل نمی‌شود و می‌تواند فقط `{"version": 1}` باشد.
- `realtime`. هم API و هم قید دیتابیس این مقدار را می‌پذیرند و **هیچ‌چیزی در بک‌اند آن را پیاده‌سازی نکرده است**. تنها کدی که خاص برخوردش می‌کند، نوشتن مستقیم عضویت را دقیق مثل `dynamic` رد می‌کند. رزروشده حسابش کنید، نه کارآمد.

**نوع بعد از ساخت قابل تغییر نیست.** به‌روزرسانی، `name` و `definition` و `description_fa` را می‌نویسد و `kind` را به عمد دست نمی‌زند. تبدیل یک مخاطب ذخیره‌شده از ثابت به پویا، عضویتش را در محاسبه بعدی بی‌سروصدا دور می‌ریخت، و تبدیل برعکس، کوئری‌ای را منجمد می‌کرد که کسی هنوز فکر می‌کند زنده است.

### از API مدیریت {#saved-public}

| متد و مسیر | مجوز | پاسخ موفق |
|---|---|---|
| `GET /v1/segments` | `segment.read` | `{"segments": [...]}` |
| `GET /v1/segments/{id}` | `segment.read` | شیء سگمنت |
| `POST /v1/segments` | `segment.write` | ۲۰۱ |
| `PUT /v1/segments/{id}` | `segment.write` | ۲۰۰ |
| `DELETE /v1/segments/{id}` | `segment.delete` | ۲۰۴ بدون بدنه |

بدنه نوشتن فقط دو فیلد دارد:

```bash
curl -X POST https://api.segmentic.net/v1/segments \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"name":"خریداران تهران","definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
```

```json
{"id": 11, "name": "خریداران تهران", "description_fa": "کاربرانی که شهر آن‌ها «تهران» است"}
```

**فیلد `kind` روی این بدنه وجود ندارد**، پس هر سگمنتی که API مدیریت بسازد `dynamic` است. فهرست ثابت را از این راه نمی‌شود ساخت.

خطاها:

| وضعیت | کد | چه وقت |
|---|---|---|
| ۴۰۰ | `name_required` | `name` تهی یا فقط فاصله |
| ۴۰۰ | `bad_id` | شناسه در مسیر عدد مثبت نیست |
| ۴۰۰ | `malformed_json` | بدنه JSON معتبر نیست |
| ۴۲۲ | `filter_invalid` | تعریف کامپایل نمی‌شود، با متن دقیق کامپایلر |
| ۴۰۴ | `not_found` | روی `PUT`: شناسه ناشناس، یا شناسه مشتری دیگر |
| ۵۰۳ | `segment_unavailable` | ذخیره یا حذف ممکن نشد |

در `PUT`، خواندن قبل از نوشتن عمدی است تا شناسه‌ای از URL مشتری دیگر ۴۰۴ بدهد و نه نوشتنی که بی‌سروصدا یک سگمنت روی حساب شما می‌سازد. `name` تهی در `PUT` یعنی «نام فعلی را نگه دار».

دو مسیر از این جدول مثل بقیه رفتار نمی‌کنند و دلیل هر دو یکی است: این‌ها handler پنل‌اند نه handler API مدیریت.

**`GET /v1/segments/{id}` خطای ۴۰۴ خودش را در پاکت مسطح می‌دهد**، `{"error": "segment not found"}`، بدون فیلد `code`. روی این مسیر `error` را هم رشته و هم شیء در نظر بگیرید.

**`DELETE /v1/segments/{id}` اصلا ۴۰۴ نمی‌دهد.** بایگانی یک `UPDATE ... WHERE tenant_id = $1 AND id = $2 AND archived_at IS NULL` است و تعداد سطر تغییرکرده خوانده نمی‌شود، پس حذف شناسه‌ای که وجود ندارد، شناسه‌ای که مال حساب دیگری است، و شناسه‌ای که قبلا حذفش کرده‌اید، هر سه دقیق مثل یک حذف واقعی `204` می‌گیرند. هیچ‌چیز در پاسخ این سه را از هم جدا نمی‌کند. اگر برایتان مهم است که سگمنت واقعا آنجا بوده، اول `GET` بگیرید.

سه چیزی که وجود ندارد و شاید انتظارش را داشته باشید. **هیچ `If-Match` و هیچ نشانه نسخه‌ای نیست**، پس دو نویسنده هم‌زمان بی‌سروصدا روی هم می‌نویسند. **هیچ کلید یکتاسازی درخواست پذیرفته نمی‌شود.** و **حذف هیچ‌وقت به‌خاطر «در حال استفاده» رد نمی‌شود**: حذف مخاطبی که یک کمپین زمان‌بندی‌شده به آن اشاره می‌کند، موفق می‌شود.

حذف در واقع بایگانی است. سطر می‌ماند و فقط `archived_at` پر می‌شود. کمپینی که پیش از این اجرا شده به این تعریف ارجاع می‌دهد، و گزارشی که نمی‌تواند بگوید یک ارسال به چه کسانی رفته، از یک فهرست کمی بلندتر بدتر است.

`GET /v1/segments` روی API مدیریت هم **صفحه‌بندی ندارد**. سقف ثابت ۲۰۰ سطر مرتب بر `updated_at` نزولی است و پارامترهای `limit` و `cursor` نادیده گرفته می‌شوند. چون همان handler پنل است، پاکت صفحه‌بندی استاندارد را هم ندارد و پاسخ `{"segments": [...]}` است. تعریف کامل هر سگمنت در هر سطر فهرست می‌آید.

**این سقف بی‌سروصداست و همین از خود سقف بدتر است.** در پاسخ نه `has_more` هست، نه `next_cursor` و نه تعداد کل. حسابی که ۲۵۰ سگمنت دارد ۲۰۰ تای تازه‌تر را می‌بیند و هرچه بگردد باز همان ۲۰۰ تاست. روی هیچ‌کدام از دو سطح مسیری وجود ندارد که به آن ۵۰ تای دیگر برسد. تنها چیزی که یکی از آن‌ها را برمی‌گرداند توی دید، ویرایش کردنش است، چون ذخیره `updated_at` را می‌نویسد. اگر بیشتر از ۲۰۰ مخاطب دارید، فهرست شناسه‌هایشان را خودتان نگه دارید: `GET /v1/segments/{id}` هرکدام را با شناسه می‌آورد و سقف ندارد.

### از پنل {#saved-panel}

پنل چهار مسیر جداگانه دارد که فقط از خود پنل قابل دسترسی‌اند. تفاوت‌های معنادارشان با API مدیریت:

- `POST /v1/segments` یک upsert است: `id` غیرصفر یعنی به‌روزرسانی، `id` غایب یعنی ساخت. پاسخ در هر دو حالت `200 {"id": 11}` است، نه ۲۰۱.
- فیلد `kind` را می‌پذیرد، پس فهرست ثابت فقط از این راه ساخته می‌شود. مقدار خارج از سه‌تایی مجاز یعنی `400 {"error": "unknown segment kind"}`.
- برای نوع `static`، جمله توصیفی با یک متن ثابت جایگزین می‌شود: «فهرست دستی، اعضا را خودتان اضافه می‌کنید». اگر این کار را نمی‌کرد، تعریف تهی به «همه کاربران» توصیف می‌شد و یک فهرست دستی چهل هزار نفره روی صفحه به‌عنوان کل پایگاه کاربران برچسب می‌خورد.
- نام تکراری خطای قید یکتایی می‌دهد که به شکل `503 {"error": "could not save segment"}` برمی‌گردد، نه ۴۰۹ و نه ۴۰۰ راهنما.
- سقف بدنه `1 MiB` است، در حالی که روی API مدیریت `8 MiB` است.

## فهرست ثابت و اعضایش {#static-lists}

فهرست ثابت جایی است که کسی آدم‌ها را در آن گذاشته: فایل اکسل یک آژانس، گزارش تسویه، برنده‌های یک قرعه‌کشی. سه مسیر دارد و **هیچ‌کدام روی API مدیریت نیستند**؛ فقط از پنل.

| متد و مسیر | مجوز |
|---|---|
| `GET /v1/segments/{id}/members` | `segment.read` |
| `POST /v1/segments/{id}/members` | `segment.write` |
| `DELETE /v1/segments/{id}/members/{user_id}` | `segment.write` |

`GET` فقط `{"size": 4670}` می‌دهد و نوع سگمنت را بررسی نمی‌کند، پس یک سگمنت پویا اینجا `size: 0` گزارش می‌شود و نه خطا.

بدنه افزودن یک فیلد دارد و شناسه کاربر و شماره موبایل و ایمیل را قاطی می‌پذیرد:

```json
{"identifiers": ["09123456789", "ali@example.ir", "u-42", "۰۹۱۲۳۴۵۶۷۸۹"]}
```

قاطی بودن عمدی است: یک فایل اکسل یک ستون دارد و بازاریاب می‌داند کدام است؛ پرسیدنش یک فیلد اضافه می‌شد که اشتباه پر می‌کنند. ارقام فارسی به لاتین و شماره‌ها به `E.164` نرمال می‌شوند. هر مقدار اول به‌عنوان شناسه کاربر و بعد به‌عنوان شماره یا ایمیل امتحان می‌شود، چون مشتری‌ای که شناسه کاربرهایش شماره موبایل است در ایران به‌اندازه کافی رایج هست.

پاسخ:

```json
{"added": 38210, "unmatched": ["09120000000"], "truncated": false, "size": 41902}
```

- سقف هر درخواست ۵۰۰۰۰ شناسه است. بیشتر از آن بریده می‌شود و `truncated: true` برمی‌گردد. فایل بزرگ‌تر از راه ورودی CSV می‌رود.
- `unmatched` همان شناسه‌ها را برمی‌گرداند، نه شمارششان، چون «۳۴۱۲ تا از ۴۰۰۰۰ نخورد» عددی است که کسی باید رویش کاری بکند و نمی‌تواند؛ او سطرها را لازم دارد تا با فایل خودش بسنجد. سقف این فهرست ۱۰۰ عضو است.
- شناسه‌ای که به **بیش از یک** پرونده بخورد رد می‌شود و در `unmatched` گزارش می‌شود.
- تکراری‌های داخل یک درخواست روی هم می‌افتند.
- افزودن به سگمنتی که ثابت نیست: `409` با متن «این سگمنت با شرط تعریف شده است؛ فقط به فهرست ثابت می‌شود کاربر اضافه کرد».
- فهرست تهی: `400` با متن «فهرست خالی است».

حذف یک عضو، یک mutation از نوع `ALTER TABLE ... DELETE` روی ClickHouse است و طبق طراحی کند است.

## بازمحاسبه عضویت {#refresh}

> [!note]
> برای سگمنت پویا **هیچ عضویت ذخیره‌شده‌ای وجود ندارد و هیچ کار بازمحاسبه‌ای هم وجود ندارد**. تعریف، همان سگمنت است و هر بار تازه اجرا می‌شود.

نتیجه‌های عملی این تصمیم:

- `last_size` همیشه صفر است و `last_computed_at` همیشه غایب. تابعی که این دو را می‌نویسد در کد هست و **هیچ فراخوانی‌ای ندارد**، پس روی نصب واقعی این ستون‌ها تا ابد خالی می‌مانند. کارت سگمنت در پنل به همین دلیل همیشه «در انتظار محاسبه» می‌نویسد و انتخابگر مخاطب کمپین هیچ‌وقت تعداد نفرات را نشان نمی‌دهد.
- ستون `refresh_cron` در اسکیمای دیتابیس هست و **هیچ کدی آن را نه می‌خواند و نه می‌نویسد**. زمان‌بندی بازمحاسبه وجود ندارد.
- مصرف‌کننده‌ها تعریف را زنده حل می‌کنند. کمپین در لحظه ارسال، کوئری کامپایل‌شده را صفحه‌به‌صفحه می‌خواند و اندازه‌اش را با تخمین می‌گیرد و زیر ۵۰۰۰ نفر به شمارش دقیق برمی‌گردد. شرط داخل [سناریو](/docs/journeys) تعریف را روی یک شناسه کاربر باریک می‌کند و می‌شمارد.

تنها جایی که عضویت یک سگمنت پویا به‌خاطر سپرده می‌شود، تریگر سناریو است. اسکنر سگمنت را صفحه‌به‌صفحه می‌خواند، با اسکن قبلی تفاضل می‌گیرد و همان تفاضل را وارد سناریو می‌کند: `segment_enter` تازه‌واردها را و `segment_exit` خارج‌شده‌ها را. **اولین اسکن بعد از انتشار سناریو فقط عضویت را ثبت می‌کند و هیچ‌کس را وارد نمی‌کند**، وگرنه انتشار یک بازگردانی برای ۴۰۰۰۰۰ مشتری خفته یعنی هر ۴۰۰۰۰۰ نفر در پنج دقیقه بعد پیام می‌گیرند. سگمنت بزرگ‌تر از ۲۵۰۰۰۰ عضو بریده می‌شود و بریدگی هم در لاگ و هم روی سطر وضعیت تریگر ثبت می‌شود.

برای فهرست ثابت، عضویت همان سطرهایی است که نوشته‌اید. جدول یک ReplacingMergeTree است که روی یک ستون نسخه با دقت نانوثانیه کلید خورده و هر خواندنی `FINAL` دارد، پس افزودن تکراری روی هم می‌افتد.

## محدودیت‌ها و پیش‌فرض‌ها {#limits}

| چیز | مقدار |
|---|---|
| بیشترین عمق تودرتویی | ۸ (ریشه عمق صفر است، پس ۹ سطح) |
| بیشترین تعداد گره | ۲۰۰، شامل هر عضو `properties` |
| بیشترین اعضای فهرست `in` | ۱۰۰۰ |
| بیشترین طول نام ویژگی، رویداد و کلید | ۱۲۸ بایت، حدود ۶۴ حرف فارسی |
| `amount` در پنجره نسبی | از ۱ تا ۱۰۰۰۰ |
| نرخ نمونه‌گیری تخمین | یک در ۱۰۰ |
| مهلت تخمین | ۳ ثانیه |
| مهلت کوئری (شمارش، پیش‌نمایش، ذخیره) | ۳۰ ثانیه |
| آستانه بازگشت به شمارش دقیق | تخمین زیر ۳۰۰۰ |
| سطرهای پیش‌نمایش | پیش‌فرض ۱۰، سقف ۱۰۰ |
| شناسه در هر درخواست افزودن عضو | ۵۰۰۰۰ |
| شناسه‌های نخورده در پاسخ | ۱۰۰ |
| سقف بدنه روی API مدیریت | `8 MiB` |
| سقف بدنه روی مسیرهای پنل | `1 MiB` |
| سقف فهرست سگمنت‌های ذخیره‌شده | ۲۰۰ سطر، بدون صفحه‌بندی |
| سقف اسکن تریگر سناریو | ۲۵۰۰۰۰ عضو |
| هزینه بودجه: validate و CRUD سگمنت | ۱ واحد |
| هزینه بودجه: count | ۲۵ واحد |

مجوزهای مربوط: `segment.read`، `segment.write`، `segment.delete`، `profile.read`، `event.read`. نقش‌های مالک، ادمین و بازاریاب هر سه مجوز سگمنت را دارند. تحلیلگر، بیننده و تأییدکننده فقط `segment.read` دارند. بیننده `profile.read` ندارد، پس می‌تواند مخاطب را بشمارد ولی نمی‌تواند پیش‌نمایشش را ببیند. جزئیات محدودیت نرخ در [سقف‌ها](/docs/limits) است.

## متن دقیق خطاهای کامپایلر {#errors}

این نه خطا همه چیزی هستند که کامپایل می‌تواند برگرداند. متنشان **انگلیسی است و ترجمه نمی‌شود**، و بی‌کم‌وکاست در فیلد `message` به شما می‌رسد.

| متن پایه | چه وقت |
|---|---|
| `segment: unknown node kind` | `kind` تهی یا ناشناس |
| `segment: group has no children` | آرایه `children` خالی |
| `segment: nesting too deep` | بیشتر از ۹ سطح |
| `segment: too many conditions` | بیشتر از ۲۰۰ گره و شرط ویژگی |
| `segment: invalid identifier` | نام ویژگی، رویداد، کلید یا تابع تجمیع نامعتبر؛ یا `segment_id` صفر؛ یا گروه یا سنجه ناشناخته |
| `segment: unsupported operator` | عملگر خارج از فهرست، یا عملگر غیرعددی روی تعامل و ریزش، یا هر چیزی جز `is_set` روی `birthday` |
| `segment: operator requires a value` | `value` نیامده، یا فهرست `in` تهی است |
| `segment: invalid time window` | `kind` یا `unit` ناشناخته، `amount` بیرون از بازه، یا کران‌های ناقص |
| `segment: list has too many values` | بیشتر از ۱۰۰۰ عضو در فهرست |

اغلبشان با مقدار مقصر بسته‌بندی می‌شوند:

```text
segment: unknown node kind: "wat"
segment: invalid identifier: trait "  "
segment: invalid time window: unit "fortnight"
segment: unsupported operator: engagement needs a numeric operator, got "contains"
```

اینها کد ماشینی پایدار نیستند. تنها کد پایداری که روی API مدیریت به آن تکیه کنید `filter_invalid` در پاکت خطاست. شرح کامل پاکت در [خطاها](/docs/errors) است.

## چیزهایی که وجود ندارند {#not-possible}

هر کدام از اینها چیزی است که یک مشتری منطقی دنبالش می‌گردد و در این محصول نیست. جمله موجه به‌جای این فهرست، یک بعدازظهر از وقت شما می‌گرفت.

- **مقایسه تاریخ روی مقدار یک ویژگی.** `type: "date"` پذیرفته و نادیده گرفته می‌شود. به‌جایش `days_until_birthday` و `days_until_signup_anniversary`.
- **توابع `first` و `last` در تجمیع**، و هر راهی برای شرط گذاشتن روی مقدار ویژگی در اولین یا آخرین رخداد یک رویداد. نزدیک‌ترین چیز موجود `days_since_last_seen` و `days_since_last_order` است که تازگی را جواب می‌دهند نه مقدار را.
- **مقایسه متنی دو ویژگی با هم.** `compare_trait` هر دو طرف را عدد می‌گیرد و ستون متنی در هر طرف با نام خودش رد می‌شود. در [مقایسه دو ویژگی](/docs/segments#trait-vs-trait).
- **شرط ترتیبی («اول A بعد B»).** شرط‌های رویداد زیرکوئری‌های مستقل‌اند که با AND به هم وصل می‌شوند.
- **شمارش مقدارهای یکتا.** `count()` سطر می‌شمارد.
- **منطقه زمانی روی پنجره.** همه‌چیز UTC است.
- **تاریخ جلالی روی سیم.** صریح رد شده است؛ جلالی فقط در جمله توصیفی.
- **`not` روی گره `trait`، `event` یا `segment`.** فقط گروه و تعامل و ریزش این فیلد را می‌خوانند.
- **OR بین شرط‌های ویژگی یک رویداد.** همیشه AND.
- **کار بازمحاسبه عضویت برای سگمنت پویا.**
- **پیاده‌سازی نوع `realtime`.** مقدار پذیرفته و ذخیره می‌شود و هیچ‌چیز به آن عمل نمی‌کند.
- **ساخت فهرست ثابت از API مدیریت.** بدنه‌اش فیلد `kind` ندارد.
- **مسیرهای عضویت فهرست ثابت روی API مدیریت.** فقط پنل.
- **مسیر تخمین و مسیر پیش‌نمایش فیلتر موقت روی API مدیریت.**
- **صفحه‌بندی روی `GET /v1/segments`** روی هیچ‌کدام از دو سطح.
- **هم‌زمانی خوش‌بینانه روی نوشتن سگمنت.** نه `If-Match`، نه نشانه نسخه.
- **رد کردن حذف یا ویرایش سگمنتی که در حال استفاده است.**
- **کلید یکتاسازی روی ساخت سگمنت.**
- **فهرست قالب‌های آماده روی سرور.** ۹ قالب پنل ثابت‌های TypeScript داخل بسته مرورگرند و هیچ مسیری آن‌ها را برنمی‌گرداند.
- **اعتبارسنجی نام رویداد در زمان کامپایل.** همان [تله](/docs/segments#trap) است.
- **ویرایشگر ویژگی رویداد، ویرایشگر تجمیع، مقایسه یک ویژگی با ویژگی دیگر، و شرط عضویت در سگمنت، در پنل.** هر چهار در زبان هستند و فقط از API نوشته می‌شوند.

اگر با ایجنت هوش مصنوعی کار می‌کنید، سه ابزار MCP برای همین صفحه وجود دارد: فهرست مخاطب‌ها، توصیف یک فیلتر و شمارش یک فیلتر. جزئیات در [MCP](/docs/mcp).

---

# ساخت سناریو

> گرافی که هر کاربر تک‌تک از آن رد می‌شود: شرط ورود، انتظار، شاخه، و شرط خروج.
> https://segmentic.net/docs/journeys

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

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

## سناریو چیست {#what-a-journey-is}

> نمودار: مسیر سناریوی سگمنتیک از شروع و شرط تا ارسال پیام، فراخوانی API یا خروج

سناریو یک گرهٔ ورود دارد، تعدادی گره بعد از آن، و هیچ راه دیگری برای ورود. هر
کسی که وارد می‌شود یک **نمونه** می‌گیرد: جای او در یک نسخهٔ مشخص از گراف، به‌علاوهٔ
دفترداری‌ای که همراهش می‌آید.

```json
{
  "tenant_id": 7,
  "journey_id": 12,
  "version": 3,
  "user_id": "u_9137",
  "current_node": "wait",
  "status": "waiting",
  "entered_at": "2026-08-01T09:00:00Z",
  "updated_at": "2026-08-01T09:00:00Z",
  "wake_at": "2026-08-01T10:00:00Z",
  "entry_count": 1,
  "variant": "",
  "is_holdout": false
}
```

وضعیت نمونه یکی از `active` و `waiting` و `completed` و `exited` است. دلیل خروج
یکی از `completed` و `exit_criteria` و `max_duration` و `goal_reached` و
`no_next_node`.

## نقاط ورود {#endpoints}

همهٔ مسیرهای سناریو روی کنترل‌پلین‌اند، یعنی همان APIای که پنل با آن حرف می‌زند.
روی میزبان مدیریتی هیچ مسیر سناریویی وجود ندارد.

> [!danger]
> **کنترل‌پلین از اینترنت مسیردهی نشده است.** در استقرار مرجع،
> `api.segmentic.net` فقط API مدیریتی را روی شنوندهٔ خودش سرو می‌کند و شنوندهٔ
> کنترل‌پلین عمدا منتشر نشده است. تنها مسیر عمومی به جدول زیر، پروکسی سمت سرور
> خود پنل است روی `https://app.segmentic.net/api/proxy/v1/...` که با کوکی نشست
> کاربر واردشده احراز هویت می‌کند و بدون آن `401` می‌دهد. کلید `sk_seg_` به آن
> نمی‌رسد. پس هرچه در این صفحه می‌خوانید کاری است که یک آدم در پنل انجام می‌دهد،
> از جمله [وارد کردن آدم‌ها از API](/docs/journeys#enter-api).

| مسیر | مجوز | چه می‌کند |
|---|---|---|
| `GET /v1/journeys` | `journey.read` | فهرست، با شمارش زنده |
| `GET /v1/journeys/{id}` | `journey.read` | گراف منتشرشده به‌همراه آمار هر گره. `?version=N` برای نسخهٔ قدیمی‌تر. |
| `POST /v1/journeys` | `journey.write` | ذخیرهٔ پیش‌نویس |
| `GET /v1/journeys/{id}/draft` | `journey.read` | نسخهٔ کاری، ایرادها و هشدارهایش |
| `POST /v1/journeys/validate` | `journey.read` | کامپایل بدون ذخیره |
| `POST /v1/journeys/{id}/simulate` | `journey.read` | اجرای آزمایشی روی یک آدم واقعی |
| `POST /v1/journeys/{id}/publish` | `journey.publish` | ثبت یک نسخه و فعال کردنش |
| `POST /v1/journeys/{id}/enter` | `journey.publish` | وارد کردن افراد نام‌برده |
| `POST /v1/journeys/{id}/{action}` | `journey.write` | `pause` و `resume` و `archive` |
| `DELETE /v1/journeys/{id}` | `journey.write` | بردن به سطل بازیافت |

مجوز `journey.publish` را فقط owner و admin و marketer دارند. از `journey.write`
جداست، چون ویرایش بوم و شروع پیام واقعی به آدم واقعی دو کار جدا هستند.

مسیرهای خواندن فقط وقتی وجود دارند که خوانندهٔ سناریو پیکربندی شده باشد و
مسیرهای نوشتن فقط وقتی که ویرایشگر پیکربندی شده باشد؛ `enter` علاوه بر آن به
مسیر ورود داده هم نیاز دارد.

> [!danger]
> **`GET /v1/capabilities` روی میزبان مدیریتی وقتی سناریوها نصب باشند
> `"journeys": true` گزارش می‌کند.** روی آن میزبان هیچ مسیر سناریویی ثبت نشده
> است. این پرچم را به‌معنی «سناریو از API مدیریتی در دسترس است» نخوانید. نیست، به
> هیچ شکلی، حتی خواندنی.

پاسخ `GET /v1/journeys` به‌شکل `{"journeys": [...]}` است، همیشه آرایه و هیچ‌وقت
`null`، و هر سطرش `{id, name, status, version, active, waiting}` است.

پاسخ `GET /v1/journeys/{id}` گراف و شمارش‌ها را با هم می‌دهد:

```json
{
  "graph": { "journey_id": 12, "version": 3, "entry_id": "trigger", "nodes": [] },
  "stats": {
    "trigger": { "entered": 4210, "exited": 0, "suppressed": 0, "waiting": 0 },
    "send":    { "entered": 3902, "exited": 0, "suppressed": 391, "waiting": 0 }
  }
}
```

این دو عمدا با هم می‌آیند: گرفتن جداگانهٔ آمار یعنی گراف برای یک لحظه با گره‌های
خالی دیده می‌شود، و کل فایده این است که آدم بدون ترک صفحه ببیند قیف از کجا نشت
می‌کند. شکست خواندن آمار نگاشت خالی می‌دهد نه صفحهٔ خطا. سناریویی که هست ولی
هیچ‌وقت منتشر نشده `404` می‌گیرد با پیام «این سناریو هنوز نسخهٔ منتشرشده ندارد»،
که با «چنین سناریویی نیست» یکی نیست و عمدا جور دیگری نوشته شده است.

## گراف {#graph}

| فیلد | JSON | توضیح |
|---|---|---|
| شناسهٔ سناریو | `journey_id` | |
| نسخه | `version` | بعد از انتشار تغییرناپذیر است |
| گره‌ها | `nodes` | |
| ورود | `entry_id` | شناسهٔ همان یک گره‌ای که آدم‌ها از آن وارد می‌شوند |
| قانون ورود مجدد | `entry_rule` | `once` یا `every_time` یا `max_n`. خالی یعنی `once`. |
| سقف ورود | `max_entries` | همراه `max_n` |
| کف فاصله | `cooldown_hours` | بر حسب ساعت، از آخرین ورود |
| بازهٔ ورود مجدد | `reentry_window` | `day` یعنی روزی یک بار، به وقت روز حساب. خالی یعنی همان `cooldown_hours`. |
| شرط خروج | `exit_criteria` | یک تعریف سگمنت. هر لحظه که بخواند، آدم را هرجا که باشد بیرون می‌برد. |
| بیشترین ماندن | `max_duration_days` | سقف مدتی که کسی می‌تواند بماند |

شناسهٔ حساب هیچ‌وقت بخشی از سند ذخیره‌شده نیست. بارگذارنده آن را می‌گذارد.

## انواع گره {#nodes}

هر گره `id` و `kind` دارد، به‌علاوهٔ `label` و `next` اختیاری، و دقیق یک شیء
پیکربندی که با نوعش می‌خواند.

```json
{
  "id": "wait",
  "kind": "wait",
  "label": "یک ساعت",
  "next": "send",
  "wait": { "kind": "duration", "amount": 1, "unit": "hour" }
}
```

ممکن است `x` و `y` هم باشند. آن‌ها جایی هستند که ویرایشگر گره را کشیده و موتور
هیچ‌وقت نمی‌خواندشان. گرافی که آن‌ها را ندارد بی‌اعتبار نیست؛ بوم خودش چیدمان را
حساب می‌کند.

مقدار **خالی** برای `next` مجاز است و سناریو را همان‌جا تمام می‌کند. مقدار `next`
که گرهٔ ناموجود را نام ببرد یال آویزان است و کامپایل نمی‌شود.

هشت نوع گره داریم: `trigger` و `wait` و `condition` و `switch` و `split` و
`action` و `goal` و `exit`.

### شروع {#node-trigger}

راه ورود. پیکربندی `trigger`:

| فیلد | معنی |
|---|---|
| `kind` | `event` یا `segment_enter` یا `segment_exit` یا `segment_periodic` یا `attribute_changed` یا `date` یا `api` |
| `event` | نام رویداد، برای `event` |
| `definition` | تعریف سگمنت به‌شکل خطی، برای نوع‌های سگمنتی |
| `segment_id` | شناسهٔ یک سگمنت ذخیره‌شده |
| `trait` و `value` | برای `attribute_changed` و `date`. مقدار خالی برای `value` یعنی هر نوشتن ناخالی روی آن ویژگی. |
| `filter` | مقایسهٔ ویژگی روی تریگر `event`: `gt` و `gte` و `lt` و `lte` و `eq` و `neq` |
| `offset_days` | برای `date`: چند روز جلوتر از آن تاریخ شلیک کند. از صفر تا ۳۶۵. |
| `hours` | برای `segment_periodic` و `date`: چه ساعت‌هایی از روز جاروب کند. بین یک تا شش ساعت. |

قاعدهٔ سگمنت **به‌شکل خطی ذخیره می‌شود، نه به‌شکل ارجاع به یک سگمنت ذخیره‌شده**.
نسخهٔ منتشرشده تغییرناپذیر است و ارجاع این را می‌شکست: ویرایش آن سگمنت ذخیره‌شده
بی‌سروصدا عوض می‌کرد که چه کسی وارد سناریویی می‌شود که با قاعده‌های دیگری بازبینی
و تایید شده بود.

مقدار `offset_days` فقط *قبل* از تاریخ اجرا می‌شود، هیچ‌وقت بعد از آن. «سه روز
بعد از تولدش» یک گرهٔ انتظار است، و آوردن هر دو در اینجا یعنی یک تأخیر واحد در
دو جا نوشته شود که سر ساعت سکوت با هم اختلاف دارند.

مقدار `hours` ساعت‌های روز است نه یک بازه، چون «هر شش ساعت» نسبت به ساعتی که
مخاطب با آن زندگی می‌کند سر می‌خورد: جاروبی که ساعت `09:00` شروع شده یک هفته بعد
جاروب `03:00` است، و پیام‌هایی که تولید می‌کند بعد با ساعت سکوت تا صبح نگه داشته
می‌شوند، به دلیلی که هیچ‌کس از روی زمان‌بندی نمی‌بیندش.

### صبر {#node-wait}

| `kind` | فیلدها | رفتار |
|---|---|---|
| `duration` | `amount` و `unit` (`minute` و `hour` و `day` و `week`) | همین حالا به‌علاوهٔ آن مدت بیدار می‌شود |
| `until_time` | `hour` از صفر تا ۲۳ و `minute` | نوبت بعدی آن ساعت دیواری **به وقت خود گیرنده** |
| `until_best_time` | `fallback_hour` از صفر تا ۲۳ | ساعتی که این آدم تاریخا در آن تعامل کرده، یا مقدار جایگزین وقتی تاریخچه کافی نیست |
| `for_event` | `event` و `timeout_amount` و `timeout_unit` و `on_timeout` | تا رسیدن رویداد یا سررسید مهلت پارک می‌شود |

مقدار `fallback_hour` روی انتظار بهترین‌زمان اجباری است و پیش‌فرض ندارد، چون
بهترین زمانی که از سه بار باز کردن حساب شده یک شیر یا خط است که لباس آمار پوشیده،
و پیش‌فرض شدن بی‌سروصدای نیمه‌شب یعنی ساعت سه بامداد به کسانی پیام می‌رود که
پلتفرم کمترین شناخت را از آن‌ها دارد.

انتظار `for_event` وقتی رویداد برسد از `next` ادامه می‌دهد و وقتی تایمر زودتر
شلیک کند از `on_timeout`. مقدار خالی برای `on_timeout` به `next` برمی‌گردد، پس هر
دو مسیر به هم می‌رسند.

### شرط و چندراهه {#node-condition}

شرط یعنی بله یا خیر:

```json
{
  "id": "bought",
  "kind": "condition",
  "condition": {
    "definition": { "root": {} },
    "on_true": "thanks",
    "on_false": "remind"
  }
}
```

چندراهه یعنی چند مسیر، به‌ترتیب بررسی می‌شود و اولین تطابق برنده است:

```json
{
  "id": "plan",
  "kind": "switch",
  "switch": {
    "cases": [
      { "label": "premium", "definition": { "root": {} }, "next": "vip" },
      { "label": "paid",    "definition": { "root": {} }, "next": "standard" }
    ],
    "default": "free"
  }
}
```

فیلد `default` اجباری است. بدون آن هرکسی که با هیچ حالتی نخواند همان‌جا از
سناریو بیرون می‌افتد، که عین یک باگ دیده می‌شود و تا وقتی گزارش مخاطب کم نیاید
نامرئی است.

ترتیبی است نه جمع‌کننده، چون همپوشانی قاعده‌ها حالت عادی است: آدم‌های طرح ویژه
همان آدم‌هایی هم هستند که خرید کرده‌اند، و فهرست مرتب تنها خوانشی است که در آن
نویسنده تصمیم می‌گیرد کدام‌یک به حساب می‌آید.

هر دو از همان زبان تعریف سگمنت استفاده می‌کنند که یک مخاطب ذخیره‌شده استفاده
می‌کند. بخش [سگمنت](/docs/segments) را ببینید.

**وقتی ارزیاب سگمنت در دسترس نباشد، شرط مسیر `on_false` و چندراهه مسیر `default`
را می‌گیرد.** این خوانش محافظه‌کارانه است و به‌جای خطا همین اتفاق می‌افتد.

### تقسیم {#node-split}

```json
{
  "id": "test",
  "kind": "split",
  "split": {
    "branches": [
      { "weight": 1, "next": "arm_a", "variant": "A" },
      { "weight": 1, "next": "arm_b", "variant": "B" }
    ],
    "holdout_percent": 10,
    "on_holdout": "end"
  }
}
```

مقدار `weight` سهم است نه درصد. موتور خودش نرمال می‌کند، پس بازاریابی که یک شاخه
را ویرایش می‌کند نمی‌تواند جمع را روی نود و هفت جا بگذارد. مقدار
`holdout_percent` بین صفر و ۱۰۰ است.

تخصیص با هش انجام می‌شود نه با قرعه: گروه کنترل از `(user_id, node_id)` و شاخه از
`(user_id, node_id + ":branch")` می‌آید، پس این دو مستقل‌اند و هر دو در تلاش مجدد
پایدار می‌مانند. کاربری که موقع تلاش مجدد بین شاخه‌ها می‌پرید نتیجه را بی‌معنی
می‌کرد.

کاربر گروه کنترل که مسیر `on_holdout` نداشته باشد از شاخهٔ اول می‌رود با
ارسال‌های متوقف‌شده. بخش [گروه کنترل](/docs/journeys#holdout) را ببینید.

### اقدام {#node-action}

پنج نوع:

| `kind` | لازم دارد | چه می‌کند |
|---|---|---|
| `send` | `channel` یا `channels`، و برای هرکدام یک قالب | پیام تحویل می‌دهد |
| `update_trait` | `trait` و `value` | روی پروندهٔ کاربر می‌نویسد |
| `webhook` | `url` | نقطهٔ ورود شما را صدا می‌زند |
| `add_to_segment` | `segment_id` | کاربر را به یک فهرست اضافه می‌کند |
| `enter_journey` | `target_journey_id` | او را به سناریوی دیگری تحویل می‌دهد |

گرهٔ ارسال می‌تواند یک کانال نام ببرد (`channel` و `template_id`) یا چند تا
(`channels`، آرایه‌ای از `{channel, template_id}`) با `channel_mode` خالی برای
جایگزینی یا `"all"` برای پخش هم‌زمان. حالت جایگزینی هرکدام را به‌ترتیب امتحان
می‌کند و روی اولینی که به آدم می‌رسد می‌ایستد. **فقط امتناعی که شکل کانال دارد
باعث رفتن به بعدی می‌شود**: دستگاه نیست، شماره نیست، این رسانه خاموش است. امتناعی
که دربارهٔ خود آدم است، مثل لغو اشتراک و سقف تعداد و ساعت سکوت، گره را برای او
کاملا متوقف می‌کند، چون امتحان کردن کانال بعدی یعنی دنبال راه دور زدن آن جواب
گشتن.

مقدارهای `priority` و `budget` روی گره‌اند نه روی سناریو، چون یک جریان هر دو نوع
پیام را دارد: «سفارشتان راه افتاد» و «شاید این را هم بپسندید» در یک سناریو
هستند و ادعای یکسانی روی توجه کسی ندارند.

اقدام `enter_journey` نمی‌تواند سناریوی خودش را هدف بگیرد.

### هدف و پایان {#node-goal}

گرهٔ هدف `{"event": "...", "window_days": N}` دارد. ایستگاه تبدیل است؛
`window_days` سقف انتساب است، چون خریدی که شش ماه بعد اتفاق افتاده کار این
سناریو نیست.

گرهٔ پایان پایانی است و پیکربندی ندارد.

## یک تعریف کامل {#definition}

سناریوی سبد رها شده، با همان JSONای که `POST /v1/journeys` می‌پذیرد:

```json
{
  "name": "سبد رها شده",
  "graph": {
    "journey_id": 1,
    "entry_id": "trigger",
    "nodes": [
      {
        "id": "trigger", "kind": "trigger", "label": "افزودن به سبد", "next": "wait",
        "trigger": { "kind": "event", "event": "product_added_to_cart" }
      },
      {
        "id": "wait", "kind": "wait", "label": "یک ساعت صبر", "next": "send",
        "wait": { "kind": "duration", "amount": 1, "unit": "hour" }
      },
      {
        "id": "send", "kind": "action", "label": "یادآوری", "next": "end",
        "action": { "kind": "send", "channel": "push", "template_id": 3 }
      },
      { "id": "end", "kind": "exit" }
    ]
  }
}
```

یک جاروب دوره‌ای با کف فاصله و شرط خروج. همین ترکیب، یعنی روزی دو بار جاروب کن،
به هیچ‌کس بیشتر از هفته‌ای یک بار پیام نده، و هرکس ثبت‌نام کرد را رها کن، شکلی
است که هیچ هشداری تولید نمی‌کند:

```json
{
  "journey_id": 1,
  "entry_id": "trigger",
  "entry_rule": "every_time",
  "cooldown_hours": 168,
  "exit_criteria": { "root": {} },
  "nodes": [
    {
      "id": "trigger", "kind": "trigger", "label": "جاروب", "next": "send",
      "trigger": {
        "kind": "segment_periodic",
        "definition": { "root": {} },
        "hours": [11, 18]
      }
    },
    {
      "id": "send", "kind": "action", "label": "یادآوری", "next": "end",
      "action": { "kind": "send", "channel": "push", "template_id": 3 }
    },
    { "id": "end", "kind": "exit" }
  ]
}
```

پاسخ `POST /v1/journeys` این است: `{"id": 12, "version": 4}`. **پیش‌نویس اجازه
دارد ناقص باشد.** یک گرهٔ تنها بدون هیچ یالی بی‌اعتراض ذخیره می‌شود، چون بازاریاب
یک جریان را در چند نشست می‌سازد و رد کردن نصف گراف یعنی از دست دادن کارش. جایی
که گراف باید سالم باشد، انتشار است.

شکست‌ها: `400 malformed JSON` و `400 name is required` و `400 graph is required`
و `503 could not save journey`.

## آدم‌ها چطور وارد می‌شوند {#entry}

قانون ورود مجدد تصمیم می‌گیرد چه کسی می‌تواند دوباره شروع کند:

| `entry_rule` | رفتار |
|---|---|
| `once` (و خالی) | هیچ‌وقت دوباره، هر اتفاقی که بار اول افتاده باشد |
| `every_time` | دوباره، مشروط به کف فاصله |
| `max_n` | تا وقتی `entry_count` به `max_entries` برسد |

کسی که همین حالا `active` یا `waiting` است **هیچ‌وقت** دوباره وارد نمی‌شود، تحت
هیچ قانونی. شروع دوبارهٔ او یعنی کل جریان را دو بار به‌طور موازی می‌گیرد. مبدأ
`cooldown_hours` آخرین به‌روزرسانی نمونهٔ قبلی است، نه زمان شروعش.

### کف فاصله دو خوانش دارد {#reentry-window}

`cooldown_hours` یک مدت است: تا N ساعت از آخرین ورود نگذرد، کسی دوباره وارد
نمی‌شود. `reentry_window: "day"` یک بازهٔ تقویمی است: هر نفر روزی حداکثر یک بار،
و شمارنده سر نیمه‌شب صفر می‌شود نه ۲۴ ساعت بعد از ورود قبلی. مبدأ بازهٔ تقویمی
زمان **ورود** نمونهٔ قبلی است، نه به‌روزرسانی‌اش، تا فاصله به طول خود جریان
وابسته نشود.

این دو یک چیز نیستند. یک پیمایش با ساعت‌های ۱۰ و ۱۸ و `cooldown_hours` برابر
۲۴، یک شب ساعت ۱۹ تعداد ۲۱۱۰ نفر را وارد کرد و روز بعد هر دو نوبتش داخل همان
۲۴ ساعت افتاد، پس ۱۵ نفر وارد شدند در برابر خط پایهٔ ۲۱۹۵. آن گروه به ساعتی که
اولین بار وارد شده قفل می‌شود و از آن به بعد روزهای کامل را از دست می‌دهد.
یادآوری روزانه یعنی «روزی یک بار»، و هیچ مدتی این را نمی‌گوید.

روز از آن حساب است، به وقت تهران، همان روزی که سقف فرکانسی روزانه در آن شمرده
می‌شود. این دو فیلد جایگزین همدیگرند نه مکمل هم: گرافی که هم `reentry_window`
داشته باشد و هم `cooldown_hours` دو جواب دارد، پس
`POST /v1/journeys/validate` آن را ایراد گزارش می‌کند و پنل منتشرش نمی‌کند. اگر
با این حال جایی ذخیره شود، موتور بازه را اجرا می‌کند و ساعت‌های کنارش هیچ کاری
نمی‌کنند.

اینکه تریگر واقعا چطور کسی را وارد می‌کند به نوعش بستگی دارد، و همین تفاوت‌ها
جایی است که آدم‌ها گیر می‌کنند.

**`event`** آنی است. رویداد روی گذرگاه می‌آید، ورکر همهٔ گراف‌های منتشرشده را
بررسی می‌کند و تطابق، آدم را وارد می‌کند. مقایسه‌های `filter` همین‌جا اعمال
می‌شوند. سناریویی که منتظر یک رویداد معمولی است هیچ‌وقت با رویداد رزروشدهٔ ورودی
که پایین‌تر توضیح داده شده وارد نمی‌شود.

**`attribute_changed`** فقط با یک فراخوان `identify` می‌خواند که واقعا آن ویژگی
را نوشته باشد، با تطابق اختیاری روی مقدار دقیق. از خود رویداد identify ارزیابی
می‌شود، پس آنی واکنش می‌دهد و هیچ‌وقت مقدار قدیمی پرونده را با تغییر اشتباه
نمی‌گیرد.

**`segment_enter` و `segment_exit` و `segment_periodic` و `date`** را یک پویشگر
پیدا می‌کند نه یک رویداد. هیچ‌کس «از سگمنت مشتری‌های خفته خارج شد» را منتشر
نمی‌کند؛ این چیز درست می‌شود چون خریدی که سه هفته پیش انجام شده از یک بازه رد
شده، و تنها راه فهمیدنش این است که دو بار نگاه کنی و مقایسه کنی.

> [!warn]
> **تریگر سگمنتی آنی نیست و ما هم آنی معرفی‌اش نمی‌کنیم.** پویشگر پیش‌فرض هر **پنج
> دقیقه** عضویت را دوباره می‌خواند. تأخیر به همین بازه محدود است. بازاریابی که
> «همان لحظه‌ای که خرید کرد» را می‌خواهد، تریگر رویدادی می‌خواهد.

سه ویژگی پویشگر باربر هستند:

- **اولین پویش هیچ‌کس را وارد نمی‌کند.** فقط ثبت می‌کند چه کسانی داخل سگمنت‌اند و
  می‌ایستد. بدون این قفل، انتشار یک سناریوی بازگرداندن برای چهارصد هزار مشتری
  خفته به همه‌شان در یک بازهٔ پویش پیام می‌دهد، و گراف بعدش کاملا درست به نظر
  می‌رسد.
- ورود از همان مسیری می‌رود که پرش `enter_journey` می‌رود، پس قانون ورود و کف
  فاصله و شرط خروج را همان کدی اعمال می‌کند که از قبل اعمالشان می‌کرد.
- **بریدگی ثبت می‌شود، هیچ‌وقت بی‌صدا نیست.** یک تفاضل تا `250000` عضو و یک جاروب
  تا `5000000` عضو محدود است و هر دو صفحه‌های `5000`تایی می‌خوانند. سگمنتی
  بزرگ‌تر از آنچه پویش می‌تواند بخواند یعنی سناریویی که بی‌سروصدا از دیدن آدم‌های
  بعد از آن سقف دست کشیده است.

ساعت‌های جاروب به وقت **تهران** خوانده می‌شوند، چون «ساعت ۱۱ و ۱۸ جاروب کن»
جمله‌ای دربارهٔ روز مخاطب است.

تریگر `date` عبارت «سه روز قبل از تولدش» را به «تعداد روز تا تولدش دقیق سه است»
ترجمه می‌کند و اگر خودش تعریفی داشته باشد با AND به آن می‌چسباند.

هر ورودی غیررویدادی، از جمله ورودی API، به‌شکل رویداد رزروشدهٔ
`journey_enter_requested` با ویژگی عددی `journey_id` سفر می‌کند. برای همین یک
ورود از API، حذف تکراری و قانون ورود مجدد و شرط خروج و دفترداری نمونه را به ارث
می‌برد و مسیر ورود دومی نمی‌سازد که تا روزی که با اولی اختلاف پیدا کند با آن
موافق باشد.

مجموعهٔ گراف‌های منتشرشده هر **سی ثانیه** دوباره بارگذاری می‌شود، پس انتشار و
توقف و ازسرگیری ظرف نیم دقیقه به مسیر ورود می‌رسد.

## انتظار و اینکه چقدر بادوام است {#waits}

**تایمرها سطرهای بادوام در پستگرس‌اند، نه زمان‌بندی در حافظه.** ری‌استارت ورکر،
استقرار تازه، کرش: تایمر همچنان سر جایش است و همچنان شلیک می‌کند.

- تایمرها به تفکیک دقیقه سطل‌بندی می‌شوند و همین است که خواندنشان را ارزان
  می‌کند. با ده میلیون آدم که روی گرهٔ «سه روز صبر کن» پارک شده‌اند، به ازای هر
  تیک یک پارتیشن کوچک خوانده می‌شود نه یک ایندکس روی همهٔ تایمرهای آینده.
- خواندن هر **یک ثانیه** با دستهٔ **۱۰۰۰**تایی انجام می‌شود، هرچند سطل‌ها دقیقه‌ای
  هستند، چون کسی که «پنج دقیقه صبر کن» گذاشته تقریب پنج دقیقه انتظار دارد و یک
  دقیقه گرد کردن روی آن دیده می‌شود.
- زمان‌بندی تازه، هر تایمر موجود برای همان آدم در همان سناریو را جایگزین می‌کند.
  **یک نفر نمی‌تواند هم‌زمان در دو جا منتظر باشد.**
- گرفتن تایمر اتمی است. تایمر گرفته‌شده به ورکر دوم داده نمی‌شود.
- خطا هنگام اجرای یک تایمر آن را گرفته و پردازش‌نشده رها می‌کند و پاس بعدی
  برش‌می‌دارد. شکست یک نفر دسته را قطع نمی‌کند: بقیه آدم‌های بی‌ربطی هستند که
  پیامشان هم همین حالا سررسیده است.
- **وضعیت قبل از فرستادن اثرها ذخیره می‌شود.** کرش بین این دو، پیامی را دوباره
  می‌فرستد که لایهٔ تحویل تکراری‌اش را حذف می‌کند، به‌جای اینکه گمش کند. گم شدن
  همان شکستی است که بعدش هیچ ردی از خودش ندارد.

انتظار ساعت دیواری به وقت خود گیرنده حساب می‌شود، پس «تا ساعت نه صبح صبر کن»
یعنی نه صبح او. انتظار بهترین‌زمان ساعت تعامل آدم را همان لحظه که لازم دارد
می‌خواند و شکست این خواندن بی‌سروصدا به `fallback_hour` برمی‌گردد.

> [!warn]
> **متوقف کردن یک سناریو، آدم‌هایی را که از قبل داخلش هستند متوقف نمی‌کند.**
> توقف، سناریو را از مجموعهٔ منتشرشده بیرون می‌برد، پس کسی تازه وارد نمی‌شود. اما
> تایمرها با شناسه و نسخه از انبار حل می‌شوند و وضعیت سناریو بررسی نمی‌شود، پس
> کسی که روی انتظار سه‌روزه پارک شده بیدار می‌شود و پیامش را می‌گیرد. اگر لازم
> است ارسال‌ها بایستد، کانال یا حساب را خاموش کنید که کلیدهای قطع برای همین
> هستند. بخش [جهت شکست](/docs/consent#failure-direction) را ببینید.

## بیرون رفتن {#exit}

سه راه خروج، که هر بار نمونه جلو می‌رود به همین ترتیب بررسی می‌شوند:

1. **`exit_criteria`** بر همه‌چیز مقدم است، آدم هرجا که باشد. همین است که
   نمی‌گذارد سناریوی سبد رها شده به کسی که خریدش را انجام داده گیر بدهد.
2. **`max_duration_days`** که سقف ماندن را می‌گذارد. کسی که پشت یک انتظار
   رویدادی گیر کرده در غیر این صورت بی‌نهایت در سناریو می‌ماند.
3. تمام شدن گراف: یک گرهٔ پایان، یک `next` خالی، یا انتهای طبیعی جریان.

بعد موتور گره‌ها را می‌پیماید، حداکثر **۱۰۰ قدم** در هر فراخوان. بیشتر از آن خطای
سقف قدم می‌دهد. کامپایلر از قبل حلقهٔ بدون انتظار را رد می‌کند؛ این یکی تور
ایمنی زمان اجراست، چون موتوری که اینجا حلقه بزند قبل از اینکه کسی متوجه شود
هزاران بار همان پیام را می‌فرستد.

دلیل خروج روی نمونه ثبت می‌شود، تا بازاریاب ببیند سناریو آدم‌ها را تبدیل کرده یا
فقط مهلتشان تمام شده.

## نسخه‌ها {#versions}

**نسخهٔ منتشرشده تغییرناپذیر است و هر آدم به نسخه‌ای سنجاق می‌شود که با آن وارد
شده.** کسی که با نسخهٔ سه وارد شده با نسخهٔ سه تمام می‌کند، حتی بعد از انتشار
نسخهٔ چهار، چون هدایت دوبارهٔ آدم نیمه‌کاره به گراف عوض‌شده یعنی انداختنش روی
گره‌ای که دیگر آن معنایی را ندارد که موقع رسیدنش داشت.

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

مسیر `GET /v1/journeys/{id}?version=N` نسخهٔ قدیمی را می‌خواند. نسخهٔ صفر، یا
نبودن این پارامتر، یعنی هرچه الان منتشر است.

## اعتبارسنجی پیش از انتشار {#validate}

مسیر `POST /v1/journeys/validate` بدنهٔ `{"graph": {...}}` می‌گیرد و `200`
می‌دهد:

```json
{ "valid": true, "problems": [], "warnings": [] }
```

فقط وقتی بدنه خوانده نشود یا گراف `null` باشد `400` می‌گیرید. بقیهٔ حالت‌ها `200`
است با یک فهرست.

**`problems` جلوی انتشار را می‌گیرد، `warnings` نمی‌گیرد.** این دو به‌جای اینکه در
هم ادغام شوند جدا نگه داشته شده‌اند، چون دو واکنش متضاد می‌خواهند. ایراد یعنی
«این نمی‌تواند برود». هشدار یعنی «این می‌رود، و این کاری است که خواهد کرد». رد
کردن انتشار یک گراف هشداردار، شکل‌هایی را می‌بست که گاهی درست‌اند، و ریختنشان در
یک فهرست به بازاریاب یاد می‌داد که فهرست توصیه‌ای است، و همین خوانش است که یک یال
آویزان را منتشر می‌کند.

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

| شرط | پیام |
|---|---|
| هیچ گره‌ای ندارد | این سناریو هیچ گره‌ای ندارد |
| گره‌ای بدون شناسه | یکی از گره‌ها شناسه ندارد |
| شناسهٔ گرهٔ تکراری | گره «%s» تکراری است |
| نقطهٔ شروع تعیین نشده | نقطه شروع مشخص نشده است |
| نقطهٔ شروع به جایی اشاره نمی‌کند | نقطه شروع به گره‌ای اشاره می‌کند که وجود ندارد |
| یالی به گرهٔ حذف‌شده | خروجی «%s» از گره «%s» به گره‌ای می‌رود که حذف شده است |
| هیچ گرهٔ اقدامی ندارد | این سناریو هیچ کاری انجام نمی‌دهد؛ یک گره ارسال اضافه کنید |
| تریگر رویدادی بدون رویداد | گره شروع «%s» رویداد ورود ندارد |
| تریگر سگمنتی بدون تعریف | گره شروع «%s» سگمنتی برای بررسی ندارد |
| جاروب بدون ساعت | گره شروع «%s» ساعت جاروب ندارد؛ بدون آن هیچ‌وقت اجرا نمی‌شود |
| بیش از شش ساعت جاروب | گره شروع «%s» بیش از %s بار در روز جاروب می‌کند |
| تریگر تاریخی بدون ویژگی تاریخ | گره شروع «%s» مشخص نکرده روی چه تاریخی اجرا شود |
| فاصلهٔ تاریخ بیرون از صفر تا ۳۶۵ | گره شروع «%s» فاصلهٔ نامعتبری تا آن تاریخ دارد |
| چندراهه بدون حالت | گره چندراهه «%s» هیچ راهی ندارد |
| چندراهه بدون پیش‌فرض | گره چندراهه «%s» مسیر پیش‌فرض ندارد؛ کسی که هیچ شرطی برایش برقرار نباشد همین‌جا از سناریو می‌افتد |
| اقدام تنظیم‌نشده | گره «%s» تنظیم نشده است |
| `enter_journey` بدون مقصد | گره «%s» مقصدی برای پرش ندارد |
| ارسال بدون کانال | گره ارسال «%s» کانال ندارد |
| ارسال با کانال خالی | گره ارسال «%s» یک کانال خالی دارد |
| ارسال بدون قالب | گره ارسال «%s» هیچ متنی برای فرستادن ندارد |
| ارسال چندکاناله با یک قالب کم | گره ارسال «%s» برای کانال %s متنی ندارد |
| انتظار تنظیم‌نشده | گره انتظار «%s» مدت ندارد |
| انتظار رویدادی بدون مهلت | گره انتظار «%s» مهلت ندارد؛ کاربری که آن رویداد را انجام ندهد برای همیشه در سناریو می‌ماند |
| تقسیم بدون شاخه | گره تقسیم «%s» هیچ شاخه‌ای ندارد |
| شرط تنظیم‌نشده | گره شرط «%s» شرطی ندارد |

هرچه این فهرست جا بیندازد را کامپایلر می‌گیرد، بالاتر از همه **حلقه‌ای که هیچ گرهٔ
انتظاری داخلش نیست**، که رد می‌شود. انتظار حلقه را می‌شکند چون باید زمان بگذرد.

کامپایلر علاوه بر فهرست بالا این‌ها را هم اجبار می‌کند: `duration` مقدار `amount`
مثبت و `unit` شناخته‌شده می‌خواهد؛ `until_time` ساعتی بین صفر و ۲۳ می‌خواهد؛
`until_best_time` مقدار `fallback_hour` بین صفر و ۲۳ می‌خواهد که به‌جای بریده شدن
رد می‌شود؛ تعریف هر حالت چندراهه باید سگمنت معتبری باشد؛ تقسیم دست‌کم یک شاخه و
بدون وزن منفی و با جمع مثبت و `holdout_percent` بین صفر و ۱۰۰ می‌خواهد؛
`update_trait` مقدار `trait` می‌خواهد؛ `webhook` مقدار `url`؛ `add_to_segment`
مقدار `segment_id`؛ و هدف یک `event`.

یک ناهمگونی را بدانید: کامپایلر برای گرهٔ ارسال قالب **لازم نمی‌داند**. فهرست
`problems` ویرایشگر لازم می‌داند. پس گرافی می‌تواند کامپایل شود و باز هم
منتشرشدنی نباشد.

هشدارها فقط به تریگرهای `segment_periodic` و `date` مربوط‌اند:

| شرط | هشدار |
|---|---|
| تریگر تاریخی با قانون ورود `once` | این سناریو هر نفر را فقط یک بار می‌پذیرد، پس برای هر کاربر فقط یک سال اجرا می‌شود |
| تریگر تاریخی با `every_time` و کف فاصلهٔ کمتر از نود روز | کف فاصله برای یک مناسبت سالانه کوتاه است |
| جاروب با قانون ورود `once` | این سناریو هر نفر را فقط یک بار می‌پذیرد، پس جاروب دوره‌ای فقط کسانی را وارد می‌کند که تا امروز وارد نشده‌اند |
| جاروب با `every_time` و بدون کف فاصله | با قانون ورود «هر بار» و بدون کف فاصله، هر جاروب به همهٔ اعضای سگمنت پیام می‌دهد |
| جاروب بدون `exit_criteria` | این سناریو شرط خروج ندارد؛ کسی که کار موردنظر را انجام دهد تا وقتی از سگمنت خارج نشود همچنان هدف جاروب است |
| سناریوی درحال‌اجرای دیگری همین امضای ورود را دارد | سناریوی «%s» همین شرط ورود را دارد؛ یک نفر از هر دو پیام می‌گیرد |

هشدار همپوشانی، اثر انگشتی از **چیزی که تریگر دم در می‌پرسد** را مقایسه می‌کند و
عمدا هرچه بعد از آن می‌آید را نادیده می‌گیرد. سناریو هیچ‌وقت دربارهٔ خودش هشدار
نمی‌دهد، و شکست این جستجو بی‌صداست نه کشنده.

## انتشار، توقف، حذف {#publish}

مسیر `POST /v1/journeys/{id}/publish` **پیش‌نویس ذخیره‌شده را دوباره اعتبارسنجی
می‌کند** به‌جای اینکه به آخرین بررسی کلاینت اعتماد کند. نظر مرورگر یک راحتی است؛
این دروازه‌ای است که تصمیم می‌گیرد پیام‌ها شروع به رفتن کنند یا نه.

```json title="400 وقتی پیش‌نویس آماده نیست"
{
  "error": "این سناریو هنوز آمادهٔ انتشار نیست",
  "problems": ["گره ارسال «send» هیچ متنی برای فرستادن ندارد"]
}
```

موفقیت `200` است با `{"version": 4}`. اگر پیش‌نویس خوانده نشود `404` و اگر خود
انتشار شکست بخورد `503`.

مسیرهای `POST /v1/journeys/{id}/pause` و `/resume` و `/archive` وضعیت را روی
`paused` و `active` و `archived` می‌گذارند و `{"status": "paused"}` جواب
می‌دهند. هر اقدام دیگری `400 unknown action` است.

مسیر `DELETE /v1/journeys/{id}` یک حذف نرم به سطل بازیافت است که **سی روز**
نگه داشته می‌شود و با `POST /v1/recycle/journey/{id}/restore` برمی‌گردد.
سناریویی که درحال اجرا یا زمان‌بندی‌شده است `409` می‌گیرد و به شما می‌گوید اول
متوقفش کنید. سناریوی از قبل حذف‌شده یا ناموجود `404` می‌گیرد، چون از این نقطه
«قبلا حذف شده» و «هیچ‌وقت نبوده» یک جواب واحد دارند: فراخوان دارد به صفحه‌ای
قدیمی نگاه می‌کند.

## وارد کردن آدم‌ها از بک‌اند خودتان {#enter-api}

نقطهٔ ورودی دقیق برای همین کار وجود دارد، و در استقرار مرجع بک‌اند شما نمی‌تواند
به آن برسد. هر دو نیمه درست‌اند و نیمهٔ دوم همان است که یک بعدازظهر خرج برمی‌دارد،
پس اول همان را می‌گوییم.

مسیر `POST /v1/journeys/{id}/enter` روی کنترل‌پلین است. شنوندهٔ کنترل‌پلین به
اینترنت منتشر نشده: `api.segmentic.net` فقط API مدیریتی را حمل می‌کند و روی آن
هیچ مسیر سناریویی ثبت نشده است. تنها مسیر عمومی، پروکسی سمت سرور خود پنل است که
کوکی نشست کاربر واردشده را می‌خواهد و کلید `sk_seg_` را رد می‌کند. پس امروز این
یک کار داخل پنل و یک یکپارچه‌سازی درون‌شبکه است، نه یک API عمومی.

خود نقطهٔ ورود:

```http
POST /v1/journeys/12/enter
Content-Type: application/json

{ "user_ids": ["u_9137", "u_4410"] }
```

```json title="202"
{
  "accepted": 2,
  "status": "queued",
  "note": "ورود پس از پردازش رویداد انجام می‌شود؛ قوانین ورود مجدد و شرط خروج همچنان اعمال می‌شوند."
}
```

- **گرهٔ ورود سناریو باید تریگر `api` باشد.** هر چیز دیگری `409` می‌گیرد. بدون
  این بررسی، فراخوان موفق می‌شود، رویداد منتشر می‌شود، ورکر تطابق نمی‌دهد، و به
  فراخوان دربارهٔ چیزی که هیچ‌وقت اتفاق نمی‌افتد گفته شده «پذیرفته شد».
- مجوز `journey.publish` است نه `journey.write`. این گراف را ویرایش نمی‌کند؛
  باعث می‌شود پیام واقعی به آدم نام‌برده برود.
- هم `{"user_id": "u1"}` را می‌پذیرد و هم `{"user_ids": ["u1","u2"]}` و هر دو را
  با هم. خالی‌ها و تکراری‌ها حذف می‌شوند و تکرار یک نفر در یک فراخوان یک نفر است.
- **حداکثر ۵۰۰ نفر در هر فراخوان.** بیشتر از آن `400 at most 500 people per call`
  است. وارد کردن یک تصمیم نفربه‌نفر است، و درخواستی که بتواند صد هزار نفر را نام
  ببرد یک کمپین است که لباس API پوشیده، بدون پیش‌نمایش مخاطب و بدون گزارش پوشش
  یک کمپین.
- سقف بدنه یک مبی‌بایت است و فیلد ناشناخته رد می‌شود.

**عدد `accepted` می‌گوید گذرگاه رویداد چند تا را گرفت، نه اینکه چند نفر سناریو
را شروع کردند.** ورکر همچنان قانون ورود مجدد و کف فاصله و شرط خروج را اعمال
می‌کند، پس بعضی از آن آدم‌ها شروع نخواهند کرد. اسم این فیلد از روی چیزی انتخاب
شده که این نقطهٔ ورود واقعا می‌تواند قولش را بدهد.

هر نفر به یک پاکت `track` تبدیل می‌شود با شناسهٔ پیامی به شکل
`jenter-{journeyID}-{userID}-{unixSeconds}`، پس تلاش مجدد داخل همان ثانیه در
کالکتور تکراری حساب می‌شود.

شکست‌ها: `400` برای شناسهٔ بد سناریو، بدنهٔ ناخوانا، فیلد ناشناخته یا فهرست خالی
کاربران؛ `404` وقتی پیش‌نویس خوانده نشود؛ `409` وقتی ورود تریگر API نباشد؛
`503 could not queue the entry`.

## شبیه‌سازی {#simulate}

مسیر `POST /v1/journeys/{id}/simulate` پیش‌نویس را روی یک آدم واقعی اجرا می‌کند و
هر تصمیم را گزارش می‌دهد. **هیچ‌چیز فرستاده نمی‌شود و هیچ‌چیز ذخیره نمی‌شود.**

```json title="درخواست"
{ "user_id": "u_9137" }
```

مقدار خالی `user_id` باعث می‌شود سرور خودش یک پروندهٔ اخیر را انتخاب کند. فرستادن
`graph` یعنی چیزی که روی بوم است تست شود نه چیزی که آخرین بار ذخیره شده.

```json title="پاسخ"
{
  "steps": [
    { "node_id": "trigger", "label": "افزودن به سبد", "kind": "visited", "detail": "..." },
    { "node_id": "wait", "label": "یک ساعت صبر", "kind": "wait", "detail": "..." },
    { "node_id": "send", "label": "یادآوری", "kind": "effect", "detail": "...",
      "effect": { "kind": "send", "channel": "push", "template_id": 3, "node_id": "send" } }
  ],
  "reached": true,
  "outcome": "...",
  "sends": 1,
  "simulated_waits": 1,
  "problems": [],
  "user_id": "u_9137"
}
```

نوع هر قدم یکی از `visited` و `branch` و `effect` و `wait` و `end` و `error`
است. یک قدم می‌تواند `suggestion` هم داشته باشد وقتی چیزی در آن ایراد دارد.

کاری که یک بررسی ساختاری نمی‌تواند بکند و این می‌کند:

- **انتظارها شتاب می‌گیرند** و `simulated_waits` می‌گوید چند تا. گرهٔ «دو روز صبر
  کن» در غیر این صورت دکمهٔ تست را دقیق روی همان سناریوهایی بی‌فایده می‌کرد که
  بیشتر از همه باید بررسی شوند. انتظار `for_event` مسیر «رویداد رسید» را
  می‌گیرد.
- **شرط خروج اول از همه بررسی می‌شود**، عین کاری که موتور می‌کند، چون سناریویی که
  شرط خروجش از قبل برای همهٔ واردشوندگان برقرار است رایج‌ترین شکل «چرا هیچ‌کس این
  را نگرفت» است: جریان درست است و هر واردشونده دم در بیرون می‌رود.
- شرط‌ها روی آدم واقعی و با همان تطبیق‌دهنده‌ای ارزیابی می‌شوند که ورکر استفاده
  می‌کند. شکست تطبیق‌دهنده به‌شکل «این شاخه گرفته نشد» گزارش می‌شود نه قطع کردن
  رد.
- فهرست `problems` همان فهرستی است که دکمهٔ انتشار می‌بیند، پس پیمودن تمیز یک
  شاخه به‌معنی منتشرشدنی بودن خوانده نمی‌شود.
- سقفش ۱۰۰ قدم است.

مجوزش `journey.read` است. گذاشتن یک اجرای آزمایشی پشت مجوز انتشار یعنی کسی که
نمی‌تواند منتشر کند، کار خودش را هم نمی‌تواند بررسی کند.

شکست‌ها: `400` برای شناسهٔ بد یا بدنهٔ ناخوانا؛
`409 no profile is available for a test run yet` وقتی حساب هیچ پرونده‌ای برای
انتخاب ندارد؛ `503` وقتی جستجوی کاربر آزمایشی شکست بخورد؛ `404` وقتی نه گراف
فرستاده شده باشد و نه پیش‌نویس ذخیره‌شده‌ای باشد.

## گروه کنترل داخل سناریو {#holdout}

آدم گروه کنترل کل گراف را می‌پیماید و هیچ پیامی نمی‌گیرد. همین است که اثر سناریو
را قابل اندازه‌گیری می‌کند، و قاعدهٔ جالب هم همین‌جاست: **دو بازو فقط باید در آنچه
فرستاده شده با هم فرق کنند.**

| اثر | برای گروه کنترل |
|---|---|
| `send` | ثبت و متوقف می‌شود، دقیق در همان نقطه‌ای که یک ارسال واقعی متوقف می‌شد |
| `update_trait` | **اعمال می‌شود** |
| `add_to_segment` | **اعمال می‌شود** |
| `webhook` | **رد می‌شود** |
| `enter_journey` | **رد می‌شود** |

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

## ارسال از داخل سناریو {#sends}

گرهٔ ارسال پیام را به همان مسیر تحویلی می‌دهد که کمپین می‌دهد، پس هر قاعده‌ای در
[رضایت و سقف](/docs/consent) روی آن هم اعمال می‌شود.

- **دستهٔ پیش‌فرض یک ارسال سناریو `marketing` است.** دستهٔ خود قالب آن را کنار
  می‌زند، و همان فیلدی است که باید برای پیام سفارش‌مانند داخل یک سناریو تنظیم
  کنید.
- شناسهٔ پیام
  `j{journey_id}.v{version}.{node_id}.{user_id}.e{entry_count}` است، و برای
  کانال دوم به بعد یک گرهٔ چندکاناله نام کانال هم به آن اضافه می‌شود. دو کانالی
  که به یک نفر می‌رسند دو پیام‌اند، و شناسهٔ مشترک باعث می‌شد دومی تلاش مجدد اولی
  حساب و بی‌سروصدا دور ریخته شود.
- **ارسالی که لایهٔ حکمرانی به تعویق بیندازد به‌عنوان ارسال معوق در صف می‌رود، نه
  اینکه دوباره روی تایمر سناریو کوک شود.** سناریو جلو می‌رود؛ پیام بعدا آزاد
  می‌شود.
- **بدون انبار تعویق پیکربندی‌شده، پیام معوق سناریو با یک هشدار دور ریخته
  می‌شود.** این چیزی است که روی نصبی که خودتان بالا نیاورده‌اید ارزش بررسی دارد.

> [!danger]
> **گرهٔ ارسالی که روی کانال `webhook` نوشته شده باشد، برای هر کسی که به آن
> برسد شکست می‌خورد.** کامپایلر فقط بررسی می‌کند که رشتهٔ کانال خالی نباشد، پس
> `"channel": "webhook"` اعتبارسنجی می‌شود، منتشر می‌شود و اجرا می‌شود. در کل
> لایهٔ تحویل هیچ فرستنده‌ای برای وب‌هوک نیست: این مقدار در واژگان کانال‌های
> قابل‌نوشتن هست و در هیچ چیزی که تحویل بدهد نیست. هر ورودی با `failed` و
> «این کانال پیکربندی نشده است» برمی‌گردد، یکی به ازای هر نفر، و گراف درست به
> نظر می‌رسد. اقدام `webhook` در جدول بالا چیز دیگری است و کار می‌کند: نقطهٔ
> ورود شما را صدا می‌زند به‌جای اینکه پیام بفرستد. فقط `push` و `sms` و `email`
> و `webpush` و `inapp` و `bale` و `eitaa` و `rubika` فرستنده دارند.

## شخصی‌سازی از روی رویداد راه‌انداز {#trigger-vars}

ارسال سناریو از همان سه منبعی رندر می‌شود که
[ارسال تراکنشی](/docs/transactional#vars) از آن رندر می‌شود، به‌علاوهٔ یک منبع
دیگر: ویژگی‌های رویدادی که این قدم را باعث شده، زیر فضای نام `event.`.

```
{{event.rival}} از تو جلو زد، الان رتبه {{event.rank}} هستی
```

رویداد یا همانی است که موقع ورود با راه‌انداز جور شده، یا همانی که انتظار برای
رویداد را تمام کرده و آدم را بیدار کرده است. هرکدام که این قدم را باعث شده
باشد.

**این فضای نام هیچ‌وقت با چیزی ادغام نمی‌شود، و نکته دقیقا همین است.** یک ویژگی
ذخیره‌شده به نام `rank` و یک ویژگی رویداد به نام `rank` دو واقعیت متفاوت‌اند و
معمولا هر دو در یک جمله لازم‌اند: `{{rank}}` همان ویژگی پروفایل است و
`{{event.rank}}` ویژگی رویداد، و هیچ‌کدام دیگری را پنهان نمی‌کند. `event.` تنها
پیشوندی هم هست که رندرکننده هرگز آن را برنمی‌دارد، پس `{{event.rank}}` ای که پر
نشده باشد یک متغیر بی‌مقدار است، نه اینکه بی‌سروصدا مقدار ویژگی پروفایل را
بگیرد.

**قدمی که رویداد ندارد، هیچ‌کدام از این متغیرها را ندارد.** انتظاری که تمام
می‌شود، راه‌انداز تاریخی و سوییپ سگمنت همه بدون رویداد ادامه می‌دهند، و این شامل
هر ارسالی است که بعد از یک گرهٔ انتظار نشسته باشد. قالبی که یکی از این‌ها را نام
ببرد آن‌وقت متغیر بی‌مقدار دارد، پس ارسال با `missing_personalisation` متوقف
می‌شود و کلیدهای غایب در لاگ ورکر نام برده می‌شوند. جایگزینش پوشی است که
« از تو جلو زد» می‌خواند و برگشت‌پذیر نیست. اگر جمله بدون آن مقدار هم
کامل است، برای جای‌نگهدار یک مقدار جایگزین بگذارید، و اگر کامل نیست، ارسال را
قبل از انتظار بگذارید.

**رویداد روی سطر نمونه نوشته نمی‌شود.** همان جا و در همان قدم خوانده و بعد دور
ریخته می‌شود، پس سناریو نمی‌تواند رتبهٔ دوشنبه را به‌جای رتبهٔ امروز نشان بدهد.
تنها جایی که این مقدارها ذخیره می‌شوند ارسالی است که ساعت سکوت تا صبح نگهش
داشته: آن پیام مقدارهایی را که با آن‌ها ساخته شده نگه می‌دارد، چون رویدادی که از
آن آمده دیگر خواندنی نیست.

**حداکثر ۳۲ ویژگی منتقل می‌شود، و فقط آن‌هایی که حداکثر ۵۱۲ بایت‌اند.** آن ۳۲ تا
اولین‌ها به ترتیب حروف‌اند، یعنی در تلاش مجدد هم همان ۳۲ تا. مقدار بلندتر بریده
نمی‌شود بلکه کنار گذاشته می‌شود، تا به‌جای جمله‌ای که وسط راه قطع می‌شود، یک متغیر
بی‌مقدار باشد.

فقط ویژگی‌ها منتقل می‌شوند. نام رویداد، زمانش و اطلاعات دستگاهش متغیر نیستند.

## کاری که سناریو نمی‌کند {#not-built}

- **روی میزبان مدیریتی هیچ مسیر سناریویی نیست**، حتی خواندنی، برخلاف پرچم
  قابلیت‌ها. و کنترل‌پلینی که این مسیرها را دارد از اینترنت مسیردهی نشده است.
  بخش [نقاط ورود](/docs/journeys#endpoints) را ببینید.
- **هیچ مسیری جواب نمی‌دهد «این آدم الان کجای این سناریوست».** نقطهٔ ورود گراف
  فقط آمار تجمعی هر گره را می‌دهد. خواندن تک‌نمونه وجود ندارد.
- **هیچ مسیری یک نفر را از سناریو بیرون نمی‌آورد.** سازوکارها `exit_criteria` و
  `max_duration_days` هستند؛ فراخوان «این کاربر را بیرون بینداز» وجود ندارد.
- **توقفی که آدم‌های داخل را متوقف کند وجود ندارد.** بخش
  [انتظار](/docs/journeys#waits) را ببینید.
- **سناریویی که خودتان ساخته‌اید جریان تایید ندارد.** آن یکی با
  `journey.publish` منتشر می‌شود و بس. سناریویی که از کتابخانه یا از پیشنهاد
  آمده فرق دارد: `review_required` دارد و انتشارش بدون بازبینی خطا برمی‌گرداند.
  بازبینی از رفتار سناریو اثر انگشت می‌گیرد، پس اگر بعد از بازبینی ویرایشش کنید
  و همان را منتشر کنید هم رد می‌شود، درست مثل تایید کمپین.
- **زمان‌بندی تکرارشوندهٔ خودش را ندارد.** نزدیک‌ترین چیز، تریگر جاروب با ساعت
  است، و ساعت‌هایش را به وقت تهران می‌خواند.
- **هیچ بررسی‌ای نیست که کانال یک گرهٔ ارسال اصلا قابل تحویل باشد.** بخش
  [ارسال از داخل سناریو](/docs/journeys#sends) را ببینید.
- **`offset_days` بعد از تاریخ وجود ندارد.** از گرهٔ انتظار استفاده کنید.

---

# ارسال پیام تراکنشی

> یک پیام به یک نفر، همین حالا: کد ورود، وضعیت سفارش، یادآوری پرداخت. با قرارداد یکتایی که ارسال دوباره را بی‌خطر می‌کند.
> https://segmentic.net/docs/transactional

پیام تراکنشی یعنی یک پیام به یک نفر، همین حالا: کد ورود، تایید سفارش، اطلاع از
ارسال مرسوله، یادآوری پرداخت. بک‌اند شما تصمیم می‌گیرد که باید فرستاده شود،
سگمنتیک می‌فرستد و همان‌جا جواب می‌دهد که چه شد.

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

## نقطهٔ ورود {#endpoint}

> نمودار: مسیر درخواست تراکنشی، کمپین و سناریو تا کانال درست ارسال

```http
POST https://api.segmentic.net/v1/messages
Authorization: Bearer sk_seg_...
Content-Type: application/json
```

کلید، کلید API است (`sk_seg_...`)، محرمانه است و جایش روی سرور شماست. کلید
نوشتن (`wk_seg_...`) داخل اپ شما منتشر می‌شود و اینجا با کد خطای مخصوص خودش رد
می‌شود، `write_key_rejected`، تا کسی که مقدار اشتباه را از یک صفحه کپی کرده
بفهمد کدام مقدار را لازم دارد، نه اینکه دنبال یک غلط تایپی بگردد.

مجوز لازم `campaign.send` است، نه `campaign.write`. نقش‌های owner و admin و
marketer آن را دارند. approver و analyst و viewer و finance ندارند. نوشتن پیش‌نویس
یک کمپین و بیرون فرستادن پیام دو کار جدا هستند و این مسیر کار دوم را انجام
می‌دهد.

این مسیر فقط وقتی وجود دارد که مسیر ارسال روی آن نصب پیکربندی شده باشد. اگر
نشده باشد اصلا ثبت نمی‌شود و مسیر پیش‌فرض mux جواب `404` می‌دهد با بدنهٔ
`{"error":{"code":"unknown_endpoint","message":"no such endpoint: POST /v1/messages, see GET /v1/capabilities"}}`.
اول `GET /v1/capabilities` را بگیرید: روی نصبی که نمی‌تواند بفرستد
`"transactional": false` گزارش می‌کند. روی ماشین محلی، API مدیریتی فقط وقتی سرو
می‌شود که `PUBLIC_API_ADDR` مقدار داشته باشد، و پیش‌فرض خالی است، پس تا مقدار
ندهید روی آن میزبان چیزی بالا نیست.

**پاسخ همیشه فارسی است و `Accept-Language` روی این میزبان هیچ اثری ندارد.**
میان‌افزار زبان روی شنوندهٔ خود پنل سوار است و روی این یکی نه، پس تشخیص زبان به
پیش‌فرضش می‌افتد که فارسی است. اسم `reason_fa` هم از همین‌جا می‌آید. هدر را
بفرستید اگر دوست دارید؛ کسی نمی‌خواندش.

## بدنهٔ درخواست {#request}

| فیلد | نوع | اجباری | پیش‌فرض |
|---|---|---|---|
| `user_id` | رشته | بله | ندارد |
| `channel` | رشته | بله | ندارد |
| `template_id` | عدد | بله | ندارد |
| `idempotency_key` | رشته | بله، در بدنه یا در هدر `Idempotency-Key` | ندارد |
| `category` | رشته | خیر | `transactional` |
| `vars` | شیء رشته به رشته | خیر | ندارد |

فیلدی برای متن پیام وجود ندارد. `template_id` اجباری است و محتوا داخل قالب
می‌ماند. دلیلش در خود کد نوشته شده: گرفتن متن به‌صورت خطی «متن پیام را روی مسیر
درخواست سامانه‌ای می‌گذارد که اغلب از داخل هندلر پرداخت صدا زده می‌شود، و هر
ارسال را بعد از وقوع غیرقابل بازبینی می‌کند». بخش [قالب‌ها](/docs/transactional#templates)
را ببینید.

**فیلد ناشناخته با `400` رد می‌شود.** کسی که به‌جای `idempotency_key` نوشته
`idempotencyKey` در غیر این صورت روی هر تلاش مجدد یک کلید تازه می‌گرفت و به ازای
هر تلاش یک پیام می‌فرستاد، یعنی همان خرابی‌ای که این نقطهٔ ورود برای جلوگیری از
آن ساخته شده، از راه غلط تایپی‌ای که هیچ‌کس در هیچ لاگی نمی‌بیندش.

سقف بدنه `262144` بایت است (`256` کیلوبایت).

اعتبارسنجی به این ترتیب اجرا می‌شود و اولین خطا همان چیزی است که به شما گفته
می‌شود:

1. `user_id` خالی نباشد
2. `template_id` صفر نباشد
3. `idempotency_key` خالی نباشد
4. `idempotency_key` با الگویش بخواند
5. `vars` حداکثر ۴۰ عضو داشته باشد
6. `category` یکی از `transactional` و `critical` یا خالی باشد
7. `channel` یکی از مقدارهای پذیرفته‌شده باشد

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

## کلید یکتایی {#idempotency}

**این کلید شناسهٔ درخواست نیست. اسم اتفاقی است که در سامانهٔ خودتان افتاده
است.** مثل `order-8821-shipped` یا `otp:2026-08-01:u_9137` یا
`invoice-5512-reminder-1`.

اهمیتش از کاری می‌آید که یک کلید تولیدشده می‌کند. هندلر پرداختی که روی هر تلاش
`uuid()` صدا می‌زند، بعد از چهار ثانیه تایم‌اوت می‌خورد و دوباره تلاش می‌کند، برای
یک اتفاق دو کلید متفاوت ساخته است. هر دو تازه‌اند. هر دو می‌فرستند. مشتری برای
یک مرسوله دو پیامک می‌گیرد و به پشتیبانی زنگ می‌زند. کلیدی که از خود سفارش و
مرحله‌اش ساخته شده، روی هر دو تلاش یک رشتهٔ واحد است، پس تلاش دوم جواب تلاش اول را
برمی‌گرداند و چیزی نمی‌فرستد.

شکل کلید: `^[A-Za-z0-9._:-]{8,200}$`. حرف، رقم، نقطه، خط تیره، زیرخط و دونقطه،
حداقل `8` و حداکثر `200` نویسه.

| پذیرفته می‌شود | رد می‌شود |
|---|---|
| `order-8821-shipped` | `short` (کمتر از هشت نویسه) |
| `otp:2026-08-01:u_9137` | `has space` |
| `a1b2c3d4` | `quote'inside` |
| `x.y_z-1:2` | `semi;colon` |

کلید فقط از فاصله‌های دو سرش پاک می‌شود و بس. **بزرگی و کوچکی حرف‌ها حفظ
می‌شود**، پس `Order-1` و `order-1` دو کلید جدا و دو پیام جدا هستند. یکی کردنشان،
دو کلیدی را که یک فراخوان سخت‌گیر شاید برای دو کار جدا به کار می‌برد در هم
می‌ریخت.

کلید می‌تواند در بدنه به اسم `idempotency_key` بیاید یا در هدر
`Idempotency-Key`. بدنه برنده است و هدر فقط وقتی خوانده می‌شود که فیلد بدنه خالی
باشد. شکل هدری برای این وجود دارد که بیشتر کلاینت‌های HTTP از قبل یک لایهٔ تلاش
مجدد دارند که همین هدر را می‌گذارد.

### کلید چه کار می‌کند {#idempotency-mechanics}

شناسهٔ پیام از کلید **مشتق** می‌شود، تولید نمی‌شود: `t{tenant_id}.{key}`. برای
حساب شمارهٔ `7` و کلید `order-8821` شناسه می‌شود `t7.order-8821`. پیشوند `t` یک
ارسال تراکنشی را در گزارش پیام‌ها و در انتساب، از کمپین (`c`) و سناریو (`j`) جدا
می‌کند.

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

دامنهٔ کلید `(tenant_id, idempotency_key)` است. یک کلید در دو حساب، دو پیام است.

رزرو کلید یک رفت‌وبرگشت است: یک `INSERT` با `ON CONFLICT` که برمی‌گرداند این
فراخوان برنده شده یا نه. خواندن و بعد نوشتن نیست، چون رسیدن هم‌زمان دو تلاش با
یک کلید حالت عادی است و «اول بخوان بعد بنویس» بینشان پنجره‌ای می‌گذارد که هر دو
از آن رد می‌شوند.

| وضعیت | چه می‌گیرید |
|---|---|
| اولین فراخوان | ارسال اجرا می‌شود و نتیجه روی کلید ذخیره می‌شود |
| تکرار کلیدی که تمام شده | همان نتیجهٔ ذخیره‌شده، با `"replayed": true`. هیچ ارائه‌دهنده‌ای صدا زده نمی‌شود. |
| تکرار کلیدی که ارسالش عمدا متوقف شده بود | همان امتناع، تکرار می‌شود. دوباره تلاش نمی‌شود. |
| تکرار وقتی تلاش اول هنوز در جریان است | `409` به‌همراه `Retry-After: 1` |
| تکرار بعد از اینکه تلاش اول خطا داده | رزرو آزاد شده، پس این تلاش اجرا می‌شود |

پاسخ تکراری، متن همان درخواست اول را برمی‌گرداند. `reason_fa` همان موقع ساخته و
ذخیره شده است، پس تغییر بعدی آن جمله به کلیدی که یک بار جواب گرفته نمی‌رسد.

### کلید تا کی به یاد می‌ماند {#idempotency-retention}

سطرهای دفتر یکتایی بعد از `API_IDEMPOTENCY_RETENTION` جارو می‌شوند، که پیش‌فرضش
**هفت روز** است. بعد از آن بازه، همان کلید دوباره می‌فرستد. کلید یک محافظ برای
تلاش مجدد است، نه سابقهٔ همیشگی چیزهایی که فرستاده‌اید.

رزروهای رهاشده، یعنی آن‌هایی که پروسه وسط ارسال مرده و جا گذاشته، بعد از
`API_STALE_RESERVATION` جارو می‌شوند که پیش‌فرضش **یک دقیقه** است. بدون این
جاروب، یک ورکر کرش‌کرده تا ابد به هر تلاش مجدد آن کلید `409` می‌داد، و چیزی که
متوقف می‌شد رسیدهای سفارش مشتری بود.

## دسته‌بندی پیام {#category}

| دسته | از چه چیزی رد می‌شود |
|---|---|
| `marketing` | از هیچ‌چیز. همهٔ قاعده‌ها اعمال می‌شوند. |
| `transactional` | همیشه از ساعت سکوت، و از سقف تعداد مگر آن سقف `transactional` را نام برده باشد |
| `critical` | از هرچه تراکنشی رد می‌شود، به‌علاوهٔ خاموش‌بودن کانال و لغو یک موضوع |

نه `transactional` و نه `critical` از توقف اپراتوری رد نمی‌شوند. کسی که زیر
بررسی تقلب یا توقیف حقوقی است هیچ پیامی نمی‌گیرد، حتی هشدار امنیتی. بخش
[رضایت و سقف](/docs/consent) را ببینید.

**دستهٔ `marketing` اینجا با `400` رد می‌شود.** متن خطا این است:
`transactional: this endpoint does not send marketing; use a campaign`. این
نقطهٔ ورود از سقف تعداد و ساعت سکوت رد می‌شود، پس پذیرفتن پیام تبلیغاتی روی آن
یعنی به هر مشتری یک راه مستند برای دور زدن قاعده‌های ارسال خودش داده‌ایم، و اولین
باری که به چشم می‌آمد، یک پیامک تبلیغاتی ساعت سه بامداد به یک فهرست کامل بود.

**مقدار ناشناخته در `category` جواب `503 message not sent` می‌گیرد، نه `400`.**
این یک ناهماهنگی واقعی در نسخهٔ امروز است و دانستنش می‌ارزد: یک غلط تایپی در
`category` شبیه قطعی سمت ما دیده می‌شود، و فراخوانی که دوباره تلاش کند باز هم
`503` می‌گیرد. اول این فیلد را نگاه کنید، بعد ما را.

نبودن `category` به `transactional` تفسیر می‌شود، هیچ‌وقت به `critical`. دستهٔ
حیاتی از خاموش‌بودن کانال رد می‌شود، پس پیش‌فرض گرفتنش یعنی فراخوانی که این فیلد
را جا انداخته، به کسی می‌رسد که آن کانال را با دست خودش خاموش کرده است.

> [!warn]
> **دستهٔ خود قالب، دستهٔ داخل درخواست شما را کنار می‌زند.** قالبی که با
> `marketing` ذخیره شده و از همین نقطهٔ ورود با `"category": "transactional"`
> فرستاده می‌شود، تبلیغاتی تحویل داده می‌شود، یعنی ساعت سکوت و سقف تعداد رویش
> اعمال می‌شوند. امتناعی که بالاتر گفته شد دربارهٔ فیلد درخواست است. حرف آخر با
> قالب است، چون همین فیلد است که نمی‌گذارد تایید سفارش تا ساعت نه صبح نگه داشته
> شود و به همان اندازه نمی‌گذارد یک تبلیغ با برچسب تراکنشی از سقف رد شود.

> [!warn]
> **کد ورود `critical` است نه `transactional`.** هر دو به خط خدماتی می‌رسند، پس
> تحویلشان یکسان به نظر می‌آید، و تفاوت فقط زیر سقف نرخ حساب خودش را نشان
> می‌دهد: سقفی که `transactional` را نام برده باشد کدهای یک‌بارمصرف شما را هم
> می‌شمارد، پس کمپین بزرگی که ساعت حساب را پر کند می‌تواند یکی را نگه دارد. کسی
> که با تبلیغات خودتان از حساب خودش بیرون بماند، سقف نرخی نیست که درست کار
> می‌کند. `critical` تنها دسته‌ای است که هیچ سیاست ذخیره‌شده‌ای به آن نمی‌رسد، هر
> چیزی که گفته باشد.
>
> نبودن `category` یعنی `transactional`، پس کد ورودی که این فیلد را نگذارد
> دقیقا در معرض همین است. صریح بگذاریدش.

## کانال‌ها {#channels}

مقدار `channel` باید دقیق یکی از این‌ها باشد:

```text
push  sms  email  webpush  inapp  bale  eitaa  rubika
```

سه مقدار که منطقی به نظر می‌رسند و رد می‌شوند:

- **`web`** رد می‌شود. مقدار درست `webpush` است. جاهای دیگر پلتفرم `web` را
  به‌عنوان نام قدیمی می‌پذیرند و شرح ابزار MCP هنوز `web` را تبلیغ می‌کند، ولی این
  نقطهٔ ورود مقایسهٔ حرف‌به‌حرف می‌کند و شرح MCP اشتباه است. `webpush` بفرستید.
- **`messenger`** رد می‌شود. آن یک چتر برای نوشتن کمپین است که به ازای هر گیرنده
  باز می‌شود. ارسال تراکنشی باید مستقیم `bale` یا `eitaa` یا `rubika` را نام
  ببرد.
- **`webhook`** اینجا رد می‌شود، و در کل پلتفرم هیچ فرستنده‌ای برای تحویلش وجود
  ندارد.

اینکه یک نصب واقعا روی کدام‌یک از این‌ها می‌تواند تحویل بدهد به پیکربندی بستگی
دارد. ارسال روی کانالی که ترابری‌اش پیکربندی نشده، نتیجهٔ `failed` می‌دهد نه خطای
اعتبارسنجی.

## قالب‌ها {#templates}

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

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

```text
GET  /v1/templates            template.read
POST /v1/templates            template.write
POST /v1/templates/preview    template.read
```

> [!danger]
> **روی میزبان مدیریتی هیچ مسیری برای قالب وجود ندارد، و کنترل‌پلین هم از
> اینترنت مسیردهی نشده است.** در استقرار مرجع، `api.segmentic.net` فقط شنوندهٔ
> مدیریتی را منتشر می‌کند. پس یکپارچه‌سازی‌ای که کلید `sk_seg_` دارد نمی‌تواند
> قالب بسازد، فهرست بگیرد، بخواند یا حذف کند. فقط می‌تواند با شناسه به یکی ارجاع
> بدهد، و آن شناسه باید از آدمی بیاید که پنل را باز کرده است.
> `GET /v1/templates/{id}` هم روی هیچ سطحی وجود ندارد و مسیر حذف هم ندارد.

مقدار `template_id` ناشناخته، بایگانی‌شده یا متعلق به حساب دیگر، جواب
`503 message not sent` می‌گیرد. نه `400` است و نه `404`، چون خطا را انبار
برمی‌گرداند نه اعتبارسنج درخواست. رزرو آزاد می‌شود، پس تلاش مجدد شما اجرا می‌شود،
و تلاش مجددتان دقیق به همان شکل شکست می‌خورد. وقتی یک ارسال بلافاصله و پشت سر هم
`503` می‌دهد، اول شناسهٔ قالب را نگاه کنید.

قالب روی مسیر ارسال **سی ثانیه** کش می‌شود. یک ویرایش در پنل تا نیم دقیقه طول
می‌کشد تا به ارسال برسد.

## شخصی‌سازی {#vars}

فیلد `vars` یک نگاشت تخت رشته به رشته است با حداکثر **۴۰ عضو**. مقدارها هرجا که
قالب `{{ key }}` نوشته باشد جایگزین می‌شوند، با یک مقدار جایگزین اختیاری بعد از
خط عمودی: `{{ first_name | مشتری عزیز }}`.

سه منبع با هم ترکیب می‌شوند، از ضعیف‌ترین، و هرکدام قبلی را کنار می‌زند:

1. متغیرهای پیام‌ها، که یک بار برای کل حساب تعریف می‌شوند
   (`GET/PUT /v1/settings/content-vars` روی API پنل)
2. پروندهٔ کاربر و اولین دستگاهش: `user_id`، `first_name`، `last_name`،
   `email`، `phone`، `city`، `region`، `country`، `language`، `full_name`،
   `order_count`، `total_revenue`، `last_order_date`، `birthday`، هر ویژگی
   ذخیره‌شده، به‌علاوهٔ `device_platform` و `app_version`
3. همین `vars` روی این درخواست

مقدارهای شما برنده‌اند و این تنها ترتیب امن است: متغیری که برای کل حساب تعریف
شده نباید بر خود آدمی که پیام برایش می‌رود بچربد، و هیچ‌چیز نباید بر مقداری
بچربد که سامانهٔ شما یک میلی‌ثانیه پیش ساخته است.

**متغیری که نه مقدار دارد و نه جایگزین، ارسال را متوقف می‌کند.** نتیجه
`suppressed` است با دلیل `missing_personalisation`، و فهرست کلیدهای گمشده در
لاگ می‌آید نه در پاسخ. رشتهٔ خالی هم گمشده حساب می‌شود. اگر رندر نه عنوان بدهد و
نه متن، دلیل `empty_content` می‌شود. برای اینکه ببینید یک مجموعه مقدار به چه چیزی
تبدیل می‌شود و کدام کلیدها گم‌اند، از `POST /v1/templates/preview` روی API پنل
استفاده کنید؛ همان رندرکننده‌ای را صدا می‌زند که مسیر ارسال صدا می‌زند.

رقم‌های لاتین داخل مقدارهای جایگزین‌شده، در عنوان و متن به رقم فارسی تبدیل
می‌شوند و جداکنندهٔ هزارگان می‌گیرند. در `deep_link` و `icon` و `image` و هرچه
داخل نگاشت `data` قالب باشد **تبدیل نمی‌شوند**، چون `myapp://order/۱۲۳۴۵` لینکی
است که اپ نمی‌تواند بخواندش. مقداری که حتی یک حرف لاتین داشته باشد رقم‌های
اسکی‌اش را نگه می‌دارد، چون گیرنده کد سفارشی مثل `AB-1234567` را قرار است در یک
جعبهٔ جستجو تایپ کند.

## پاسخ {#response}

جواب `200` با این بدنه:

| فیلد | نوع | چه وقت هست |
|---|---|---|
| `message_id` | رشته | همیشه |
| `status` | رشته | همیشه |
| `sent_at` | زمان `RFC3339` | همیشه |
| `reason` | رشته | فقط وقتی پیام عمدا فرستاده نشده |
| `reason_fa` | رشته | فقط وقتی `reason` هست. جملهٔ آماده، روی این میزبان همیشه فارسی. |
| `error` | رشته | فقط وقتی چیزی شکست خورده |
| `replayed` | بولین | فقط وقتی درست است |

مقدار `status` یکی از این‌هاست:

| وضعیت | معنی |
|---|---|
| `sent` | دست‌کم یک ترابری آن را پذیرفت |
| `suppressed` | عمدا نفرستادیم. `reason` می‌گوید چرا. |
| `deferred` | نگه داشته شد تا بعد دوباره تلاش شود |
| `failed` | همهٔ ترابری‌ها ردش کردند. `error` حرف درگاه را دارد. |

یک ارسال موفق:

```json
{
  "message_id": "t7.order-8821-shipped",
  "status": "sent",
  "sent_at": "2026-08-01T12:00:00Z"
}
```

تکرار کلیدی که ارسال اولش رد شده بود:

```json
{
  "message_id": "t7.order-8821-shipped",
  "status": "suppressed",
  "reason": "channel_opt_out",
  "reason_fa": "کاربر این کانال را خاموش کرده است",
  "replayed": true,
  "sent_at": "2026-08-01T12:00:00Z"
}
```

> [!warn]
> **کد `200` نمی‌گوید پیام رفته است.** هر سه حالت `suppressed` و
> `deferred` و `failed` هم با `200` می‌آیند، چون به درخواست درست جواب داده شده و
> ارسال انجام نشده. دلیل اینکه یک پیامک تراکنشی بدون الگوی تاییدشده با `200` و
> `"status": "failed"` برمی‌گردد این است که درخواست سالم بوده و حساب آمادهٔ ارسال
> نبوده. روی `status` شرط بگذارید، نه فقط روی کد HTTP.

## همهٔ شکست‌ها {#failures}

| کد | بدنه | علت | چه کار کنید |
|---|---|---|---|
| `400` | `malformed JSON: ...` | بدنهٔ ناخوانا، فیلد ناشناخته، یا بدنهٔ بزرگ‌تر از ۲۵۶ کیلوبایت | بدنه را درست کنید. تلاش مجدد کمکی نمی‌کند. |
| `400` | `transactional: user_id is required` | `user_id` ندارد | بدنه را درست کنید |
| `400` | `transactional: template_id is required` | `template_id` نیست یا صفر است | بدنه را درست کنید |
| `400` | `transactional: idempotency_key is required` | نه در بدنه کلید هست نه در هدر | بدنه را درست کنید |
| `400` | متن مربوط به شکل کلید | کلید با `^[A-Za-z0-9._:-]{8,200}$` نمی‌خواند | کلید را درست کنید. کلید تصادفی تازه نسازید. |
| `400` | `transactional: unknown channel` | جزو آن هشت مقدار نیست | بخش [کانال‌ها](/docs/transactional#channels) |
| `400` | `transactional: this endpoint does not send marketing; use a campaign` | `"category": "marketing"` | کمپین بسازید |
| `400` | `transactional: too many variables` | بیش از ۴۰ عضو در `vars` | کمتر بفرستید |
| `401` | `{"error":{"code":"unauthenticated"}}` | کلید ندارید یا کلید بد است | کلید را بررسی کنید |
| `401` | `{"error":{"code":"write_key_rejected"}}` | کلید `wk_seg_` فرستاده‌اید | کلید `sk_seg_` را بگذارید |
| `401` | `{"error":{"code":"key_expired"}}` | کلید منقضی شده | کلید تازه بسازید |
| `403` | `{"error":{"code":"forbidden","need":"campaign.send"}}` | کلید این مجوز را ندارد | مجوز `campaign.send` بدهید |
| `404` | `{"error":{"code":"unknown_endpoint"}}` | این نصب مسیر ارسال ندارد، پس این مسیر اصلا ثبت نشده | از اپراتورتان بپرسید و `GET /v1/capabilities` را بخوانید |
| `409` | `transactional: a message with this idempotency key is already in flight` و `Retry-After: 1` | تلاش قبلی خودتان هنوز در جریان است | یک ثانیه صبر کنید و همان کلید را دوباره بفرستید |
| `429` | `rate limit exceeded: N requests per minute` و `Retry-After: 60` | سقف دقیقه‌ای حساب | عقب بکشید |
| `429` | `{"error":{"code":"budget_exhausted"}}` و `Retry-After: 60` | بودجهٔ وزنی میزبان مدیریتی | عقب بکشید |
| `503` | `budget_unavailable` | بودجه خوانده نشد و این بررسی بسته می‌شکند | دوباره تلاش کنید |
| `503` | `message not sent` | قالب بارگذاری نشد، یا `category` مقدار ناشناخته داشت، یا مسیر ارسال خطا داد | `template_id` و `category` را نگاه کنید و بعد دوباره تلاش کنید |
| `200` | نتیجهٔ کامل | پیام رفت و نوشتن دفتر یکتایی شکست خورد | آن را رفته حساب کنید. دوباره نفرستید. |

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

## سقف نرخ و دو پوشش خطا {#limits}

دو سازوکار جدا اعمال می‌شوند و عمدا در دو جهت مخالف می‌شکنند.

**سقف نرخ هر حساب.** روی هر درخواست از خود حساب خوانده می‌شود و اگر نبود از
پیش‌فرض نصب یعنی `API_RATE_PER_MINUTE` که مقدارش **صفر** است، و صفر یعنی
اندازه‌گیری کلا خاموش. وقتی سقفی برقرار باشد، هدرهای `X-RateLimit-Limit` و
`X-RateLimit-Remaining` روی هر پاسخ می‌آیند نه فقط روی امتناع‌ها، چون فراخوانی که
نمی‌بیند چقدر به سقف نزدیک است هیچ راهی ندارد قبل از رد شدن سرعتش را کم کند. عبور
از سقف `429` است با `Retry-After: 60`.

پنجره یک سطل ثابت یک‌دقیقه‌ای است، نه پنجرهٔ لغزان. یک فراخوان می‌تواند روی مرز دو
پنجره چیزی نزدیک به دو برابر سقف بفرستد. این معامله عمدی است و کنار خود کد
نوشته شده.

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

**بودجهٔ وزنی میزبان مدیریتی.** مقدار `PUBLIC_API_BUDGET_PER_MINUTE` پیش‌فرض
**۶۰۰** واحد در دقیقه به ازای هر کلید است. هزینهٔ `POST /v1/messages` یک واحد
است. عبور از بودجه `429 budget_exhausted` است با `Retry-After: 60`. **این یکی
بسته می‌شکند:** بودجه‌ای که قابل بررسی نباشد `503 budget_unavailable` می‌دهد.

> [!note]
> **شکل خطا روی این مسیر یکدست نیست.** روی میزبان مدیریتی، امتناع‌های احراز
> هویت و مجوز از پوشش عمومی استفاده می‌کنند، یعنی
> `{"error":{"code":"...","message":"...","need":"..."}}`. هرچه خود هندلر
> می‌سازد، یعنی همهٔ `400`ها و آن `409` و `429` سقف نرخ و `503`، شکل تخت
> `{"error":"متن"}` را دارد. کدی که خطاهای این یک مسیر را می‌خواند باید هر دو
> شکل را بخواند.

## پیامک ایران: شرط الگو {#sms-patterns}

**پیامک تراکنشی بدون الگوی تاییدشده، روی درگاه شکست نمی‌خورد. اصلا به درگاه
نمی‌رسد.** داخل سگمنتیک رد می‌شود و با `"status": "failed"` و
`"error": "sms: a service line requires an approved pattern"` برمی‌گردد.

در ایران دو نوع خط پیامک هست و اشتباه انتخاب کردن، به دو شکل متفاوت و گران شکست
می‌خورد.

| خط | نامش | رفتار |
|---|---|---|
| تبلیغاتی | خط تبلیغاتی | ارزان، و برای هر مشترکی که پیامک تبلیغاتی را نزد اپراتورش بسته باشد نامرئی است، که سهم بسیار بزرگی از شماره‌های ایران است. درگاه باز هم موفقیت گزارش می‌کند. |
| خدماتی | خط خدماتی | به همه می‌رسد، از جمله همان مشترکان. و دقیق به همین دلیل فقط می‌تواند محتوای تراکنشی حمل کند، آن هم فقط متنی که اپراتور از پیش تایید کرده باشد. |

خط از روی دسته انتخاب می‌شود، نه توسط کسی که پیام را می‌نویسد: `marketing` روی
خط تبلیغاتی می‌رود و بقیه روی خط خدماتی. این عمدا در سطح کمپین قابل تنظیم نیست،
چون تنها جایی که یک بازاریاب سراغ دور زدنش می‌رود، دقیق همان جایی است که خط
خدماتی مشتری را از دستش درمی‌آورد.

پس هر پیامی که از `POST /v1/messages` می‌رود به خط خدماتی می‌رسد، و خط خدماتی
الگو می‌خواهد. **الگو** یعنی متن پیام همان‌طور که اپراتور تاییدش کرده، با
جای‌خالی‌های نام‌دار، که زیر یک کد ثبت شده است. درگاه هر متنی روی خط خدماتی را که
با یک الگوی ثبت‌شده نخواند رد می‌کند.

این یک بار در پروداکشن اتفاق افتاده است. یک مهاجرت در همین مخزن ثبت کرده که ستون
`pattern_code` قالب هیچ‌وقت پر نشده بود، «پس هر کد سفارش و هر کد ورود شکست
می‌خورد».

### ثبت الگو {#pattern-registering}

ثبت در دو جا انجام می‌شود و فقط یکی از آن دو سگمنتیک است.

1. **نزد اپراتور یا درگاه شما.** متن را می‌فرستید و آن‌ها تایید می‌کنند. سگمنتیک
   هیچ اتصالی ندارد که این کار را بکند و هیچ اتصالی ندارد که از کاوه‌نگار یا
   `SMS.ir` بپرسد یک کد تایید شده یا نه.
2. **داخل سگمنتیک**، تا مسیر ارسال جواب را بدون پرسیدن از کسی بداند:

```text
GET  /v1/sms-patterns              template.read
POST /v1/sms-patterns              template.write
POST /v1/sms-patterns/{id}/status  template.write
```

این‌ها روی API پنل هستند. **روی میزبان مدیریتی هیچ مسیری برای الگوی پیامک وجود
ندارد**، پس ثبت الگو و وضعیت تاییدش از یک یکپارچه‌سازی خودکار نمی‌شود.

مسیر `POST /v1/sms-patterns` این‌ها را می‌گیرد: `provider_id` (اجباری و ناصفر)،
`pattern_code` (اجباری)، `body` (اجباری) و آرایهٔ اختیاری `variables`. الگوی
ذخیره‌شده را برمی‌گرداند: `id`، `provider_id`، `pattern_code`، `body`،
`variables`، `status`، `status_note`، `created_at`، `updated_at`.

مسیر `POST /v1/sms-patterns/{id}/status` بدنهٔ `{"status": "...", "note": "..."}`
می‌گیرد. حرکت‌های مجاز این‌هاست:

```text
pending   ->  approved | rejected     اپراتور جواب داد
approved  ->  revoked                 اپراتور پسش گرفت
any       ->  pending                 متن ثبت‌شده ویرایش شد
```

این گذارها در خود SQL و داخل شرط `WHERE` اعمال می‌شوند، پس دو نفر که هم‌زمان
جواب بدهند نمی‌توانند هر دو برنده شوند. ویرایش `body` یک الگوی تاییدشده آن را به
`pending` برمی‌گرداند و یادداشتش را پاک می‌کند؛ ویرایش فقط متغیرها، یا ذخیرهٔ
دوبارهٔ متنی که عوض نشده، وضعیت را دست نمی‌زند. الگوی پس‌گرفته‌شده با فراخوان
وضعیت دوباره تایید نمی‌شود، دوباره ثبت می‌شود و روی `pending` می‌نشیند. یکتایی
روی `(حساب، ارائه‌دهنده، کد الگو)` است.

### وصل کردن الگو به قالب {#pattern-binding}

دو فیلد روی قالب این کار را می‌کنند:

- `pattern_code`: کد اپراتور
- `pattern_tokens`: نگاشتی از **نام توکن‌های درگاه** به **نام متغیرهای خودتان**

این دو فضای نام واقعا با هم فرق دارند. مال شما `first_name` و `order_id` است؛
مال آن‌ها اغلب `token` و `token2` و `token3` است.

> [!warn]
> **توکن الگو از نگاشت `data` قالب پر می‌شود، نه مستقیم از `vars`.** مسیر ارسال
> مقدار را از `data` رندرشده برمی‌دارد، پس متغیری که در `vars` می‌فرستید فقط وقتی
> به درگاه می‌رسد که نگاشت `data` قالب کلیدی داشته باشد که آن را رندر کند.

شکل درست، برای الگویی که متن تاییدشده‌اش «کد ورود شما: %token%» است و نام توکنش
`token`:

```json title="قالب"
{
  "name": "کد ورود",
  "channel": "sms",
  "category": "transactional",
  "body": "کد ورود شما: {{code}}",
  "data": { "code": "{{code}}" },
  "pattern_code": "verify-login",
  "pattern_tokens": { "token": "code" }
}
```

```json title="ارسال"
{
  "user_id": "u_9137",
  "channel": "sms",
  "template_id": 42,
  "idempotency_key": "otp:2026-08-01:u_9137",
  "vars": { "code": "8391" }
}
```

مقدارهایی که از `data` رد می‌شوند رقم‌های لاتینشان را نگه می‌دارند، چون `data` را
ماشین می‌خواند.

تایید الگو همراه خود قالب و در یک کوئری خوانده و برای همان سی ثانیه کش می‌شود، با
`EXISTS (... status = 'approved')` روی کد الگو. ارائه‌دهنده عمدا جزو این شرط
نیست، چون اینکه کدام درگاه پیام را حمل می‌کند موقع ارسال با مسیریابی تصمیم
گرفته می‌شود.

### بدون الگو چه می‌شود {#pattern-missing}

| شرط | نتیجه |
|---|---|
| خط خدماتی، قالب `pattern_code` ندارد | قبل از درگاه رد می‌شود: `failed` و `sms: a service line requires an approved pattern` |
| خط خدماتی، کد هست ولی الان تایید نیست | قبل از درگاه رد می‌شود: `failed` و `sms: this pattern is not approved by the operator` |
| شماره به‌عنوان موبایل ایران خوانده نمی‌شود | `failed` و `sms: not a valid Iranian mobile number` |
| اصلا خط خدماتی پیکربندی نشده | `failed` و بدون ارائه‌دهنده. بی‌سروصدا روی خط تبلیغاتی برنمی‌گردد. |

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

دو قاعدهٔ دیگر از همین یک واقعیت درمی‌آید:

- **کوتاه‌کردن لینک روی خط خدماتی هرگز اعمال نمی‌شود.** ملاک، وجود کد الگوست.
  بازنویسی هر تکه از یک الگوی تاییدشده باعث می‌شود دیگر با آن نخواند.
- **پانویس لغو هیچ‌وقت به متن تراکنشی اضافه نمی‌شود.** به متن یک پیامک تبلیغاتی
  `لغو۱۱` اضافه می‌شود؛ متن تراکنشی دقیق همان‌طور که تایید شده رها می‌شود، به همان
  دلیل.

## نمونه‌های کامل {#examples}

### curl {#example-curl}

```bash
curl -sS -X POST https://api.segmentic.net/v1/messages \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "channel": "sms",
    "template_id": 42,
    "category": "transactional",
    "idempotency_key": "order-8821-shipped",
    "vars": { "code": "8391" }
  }'
```

```json title="200"
{
  "message_id": "t7.order-8821-shipped",
  "status": "sent",
  "sent_at": "2026-08-01T12:00:00Z"
}
```

### Node {#example-node}

حلقهٔ تلاش مجدد همان تکه‌ای است که ارزش کپی کردن دارد. همان کلید را تکرار می‌کند،
پس تکراری که روی یک ارسال تمام‌شده بنشیند جواب ذخیره‌شده را می‌گیرد نه یک ارسال
دوباره.

```js title="send.js"
const KEY = process.env.SEGMENTIC_API_KEY; // sk_seg_...

async function sendTransactional(payload) {
  for (let attempt = 0; attempt < 4; attempt++) {
    const res = await fetch("https://api.segmentic.net/v1/messages", {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });

    if (res.status === 409 || res.status === 429 || res.status === 503) {
      const wait = Number(res.headers.get("Retry-After") || 1);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue; // همان کلید، هیچ‌وقت کلید تازه
    }

    const out = await res.json();
    if (!res.ok) throw new Error(`segmentic ${res.status}: ${JSON.stringify(out)}`);
    return out;
  }
  throw new Error("segmentic: gave up after 4 attempts");
}

// یک مرسوله، یک کلید، ساخته‌شده از خود مرسوله.
const result = await sendTransactional({
  user_id: "u_9137",
  channel: "sms",
  template_id: 42,
  category: "transactional",
  idempotency_key: "order-8821-shipped",
  vars: { code: "8391" },
});

if (result.status !== "sent") {
  // suppressed یا deferred یا failed. همهٔ این‌ها با HTTP 200 می‌آیند.
  console.warn("not delivered:", result.status, result.reason, result.error);
}
```

## کاری که این نقطهٔ ورود نمی‌کند {#not-built}

هرکدام از این‌ها در کد نیست، نه اینکه فقط مستند نشده باشد.

- **جستجو با کلید یکتایی وجود ندارد.** وقتی پاسخ را از دست دادید، روی میزبان
  مدیریتی مسیری نیست که جواب بدهد «کلید فلان چه ساخت». اگر شناسه پیام را دارید،
  گزارش پیام‌ها روی API پنل با `GET /v1/messages?message_id=...` همان ردیف را
  پیدا می‌کند. جست‌وجوی کاربر، گیرنده و بازه زمانی هم در همان مسیر وجود دارد.
- **مسیر گزارش پیام‌ها روی میزبان مدیریتی نیست.** `POST /v1/messages` هست؛
  `GET /v1/messages` نیست.
- **مسیرهای قالب روی میزبان مدیریتی نیستند.** بخش
  [قالب‌ها](/docs/transactional#templates) را ببینید.
- **مسیرهای الگوی پیامک روی میزبان مدیریتی نیستند.** بخش
  [ثبت الگو](/docs/transactional#pattern-registering) را ببینید.
- **زمان‌بندی ندارد.** فیلدی برای «فلان ساعت بفرست» وجود ندارد. این فراخوان یا
  همین حالا می‌فرستد یا نمی‌فرستد.
- **لغو و فراخوانی برگشت ندارد.** فراخوانی برگشت روی پیامی اعمال می‌شود که در
  صف منتظر مانده باشد. این یکی منتظر نمی‌ماند.
- **پیوست و محتوای خطی از هیچ نوعی ندارد.**
- **برای یک پیام، فراخوان برگشتی رسید تحویل ندارد.** رسید اپراتور پیامک جمع
  می‌شود ولی فقط به‌صورت تجمعی و در گزارش یک کمپین دیده می‌شود. چیزی که می‌توانید
  مشترکش شوید جریان معمولی رویدادهاست: `message_sent` و `message_failed` مثل هر
  رویداد دیگری هستند، پس یک رله می‌تواند بیرون بفرستدشان. بخش
  [وب‌هوک](/docs/webhooks) را ببینید.

---

# رضایت، لغو اشتراک و سقف ارسال

> چه چیزهایی جلوی یک ارسال را می‌گیرند و چرا این یک قابلیت است نه یک مانع.
> https://segmentic.net/docs/consent

این صفحه فهرست هر چیزی است که می‌تواند جلوی یک پیام را بگیرد. آن را به‌عنوان
قابلیت بخوانید، چون قابلیت است.

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

## هر چیزی که جلوی ارسال را می‌گیرد {#what-stops-a-send}

> نمودار: تصمیم ارسال یا توقف بر اساس رضایت، ترجیح موضوع و ساعت سکوت

هرکدام از این‌ها با کد دلیل خودش روی پیام ثبت می‌شود، تا گزارش بتواند جواب بدهد
«سگمنت گفت شصت هزار نفر، چرا چهل‌ویک هزار نفر گرفتند». همین سؤال رایج‌ترین
سؤالی است که به پلتفرمی مثل این می‌رسد، و همانی است که معمولا نمی‌تواند جوابش را
بدهد.

| متوقف‌کننده | روی چه چیزی | کد دلیل |
|---|---|---|
| توقف اپراتوری | همه‌چیز، حتی هشدار امنیتی | `suppressed` |
| گروه کنترل کلی حساب | تبلیغاتی | `global_holdout` |
| کانالی که آدم خاموشش کرده | همه‌چیز جز `critical` | `channel_opt_out` |
| موضوعی که لغوش کرده | همه‌چیز جز `critical` | `topic_opt_out` |
| لغو اشتراک کلی | تبلیغاتی | `unsubscribed` |
| توقف موقت خودخواسته | تبلیغاتی | `paused` |
| انقضای خود پیام | همه‌چیز | `expired` |
| سقف تعداد | پیش‌فرض روی تبلیغاتی | `frequency_cap` |
| سقف نرخ حساب | هرچه خودش نام ببرد، هیچ‌وقت `critical` | `rate_limited` |
| سقف کاهش‌یافته برای کسی که دیگر تعامل نمی‌کند | پیش‌فرض روی تبلیغاتی | `fatigued` |
| قاعدهٔ فاصلهٔ خود کمپین | همان کمپین | `too_soon` |
| ساعت سکوت | پیش‌فرض روی تبلیغاتی | `quiet_hours` |
| قاعدهٔ آخر هفتهٔ ایران | پیش‌فرض روی تبلیغاتی | `weekend` |
| گروه کنترل یک کمپین یا سناریو | همان کمپین یا سناریو | `holdout` |
| کلید قطع | هرچه آن کلید پوشش می‌دهد | `switched_off` |
| فراخوانی برگشت بعد از صف | همان پیام | `recalled` |
| باختن جا به پیام مهم‌تر | همان پیام | `arbitrated` |
| نبود دستگاه و شماره و گفتگو | همه‌چیز | `not_reachable` |
| متغیر قالب بدون مقدار و بدون جایگزین | همان پیام | `missing_personalisation` |
| رندری که هیچ‌چیز تولید نکرد | همان پیام | `empty_content` |
| تمرین (حساب در حالت آزمایشی) | همه‌چیز | `dry_run` |
| خوانده‌نشدن جستجویی که تصمیم به آن وابسته است | همه‌چیز | `*_unavailable` |

سه دسته تعیین می‌کنند کدام‌یک از این‌ها اعمال می‌شوند.

| دسته | از چه رد می‌شود |
|---|---|
| `marketing` | از هیچ‌چیز |
| `transactional` | همیشه از ساعت سکوت، و از یک سقف مگر آن سقف در `applies` خودش `transactional` را نام برده باشد |
| `critical` | از همهٔ بالایی‌ها، به‌علاوهٔ خاموش‌بودن کانال و لغو یک موضوع، و هیچ سیاستی نمی‌تواند عوضش کند |

**دستهٔ حیاتی هیچ‌وقت زیر هیچ سقفی نمی‌رود، هر چیزی که سیاست گفته باشد.** این در
خود کد اعمال می‌شود نه در اعتبارسنجی هنگام نوشتن، پس هیچ سیاست ذخیره‌شده‌ای، به هر
شکلی که ساخته شده باشد، نمی‌تواند خلافش را بیان کند. هشدار تقلب یا اطلاع تغییر
رمز که پشت یک سقف تعداد بماند، همان شکستی است که کل این لایه برای جلوگیری از آن
وجود دارد، و «مشتری خودش این‌طور تنظیمش کرده بود» دفاعی نیست که بعدا کسی
بپذیرد.

دسته از قالب می‌آید نه از فراخوان. همان فیلدی است که نمی‌گذارد تایید سفارش با
ساعت سکوت تا نه صبح بماند و به همان اندازه نمی‌گذارد یک تبلیغ با برچسب تراکنشی
از سقف تعداد رد شود.

## ترتیب اجرای قاعده‌ها {#order}

ترتیب، جزئیات پیاده‌سازی نیست. تعیین می‌کند گزارش کدام دلیل را نشان بدهد، و یکی
از قدم‌هایش تعیین می‌کند که عدد اثربخشی اصلا معنایی دارد یا نه.

| قدم | قاعده | روی چه چیزی |
|---|---|---|
| ۱ | توقف اپراتوری. هیچ‌چیز زیر این خط نمی‌تواند پیام را دوباره مجاز کند. | همه‌چیز |
| ۲ | گروه کنترل کلی حساب | تبلیغاتی |
| ۳ | خاموش‌بودن کانال | همه‌چیز جز `critical` |
| ۴ | لغو موضوع، وقتی پیام موضوعی را نام برده باشد | همه‌چیز جز `critical` |
| ۵ | لغو اشتراک کلی | دسته‌هایی که از محدودیت‌ها رد نمی‌شوند، یعنی تبلیغاتی |
| ۶ | توقف موقت | تبلیغاتی |
| ۷ | انقضا: `valid_until` گذشته است | همه‌چیز |
| ۸ | حیاتی همین‌جا مجاز است، فقط مشروط به قدم آخر | `critical` |
| ۹ | سقف تعداد و سقف نرخ حساب | تبلیغاتی، به‌علاوهٔ هر دسته‌ای که سقفی در `applies` نامش برده باشد |
| ۱۰ | ساعت سکوت و قاعدهٔ آخر هفته | فقط تبلیغاتی |
| ۱۱ | گروه کنترل | کمپین یا سناریوی مربوطه |

دو تا از این جایگاه‌ها باربرند.

**انقضا قبل از هر چیزی که به تعویق می‌اندازد بررسی می‌شود.** ساعت سکوت پیام را به
صبح می‌راند و قاعدهٔ آخر هفته آن را از جمعه رد می‌کند، پس «بازی امشب» شنبه
می‌رسد مگر کسی بگوید کی دیگر مهم نیست. بررسی‌اش بعد از رضایت و قبل از بقیه هم
یعنی پیام منقضی به‌عنوان سقف‌خورده یا ساعت‌سکوت‌خورده گزارش نمی‌شود، که در آن حالت
کسی می‌رفت قاعده‌ای را تنظیم کند که هیچ ربطی به ماجرا نداشت.

**گروه کنترل آخر از همه بررسی می‌شود، بعد از سقف تعداد و ساعت سکوت.** گروه کنترلی
که هنوز آدم‌هایی را دارد که از سقف روزانه‌شان رد شده‌اند، درحالی‌که گروه هدف دقیق
همان‌ها را فیلتر کرده، خط پایه نیست. آدم خسته کمتر تبدیل می‌شود، پس نگه داشتنش در
بازوی کنترل و بیرون گذاشتنش از بازوی هدف، هر کمپین را بهتر از آنچه بوده نشان
می‌دهد، به‌اندازه‌ای که با حجم ارسال حساب تغییر می‌کند. اول رضایت و آخر گروه
کنترل، همان چیزی است که هر دو بازو را به‌معنی «آدم‌هایی که این پیام را
می‌گرفتند» درمی‌آورد.

## رضایت: سه حالت، سه قول متفاوت {#consent-states}

این سه عمدا هم‌ارز نیستند.

| حالت | قول | چه چیزی را می‌بندد |
|---|---|---|
| `suppressed` | اپراتور این آدم را کنار گذاشته: بررسی تقلب، توقیف حقوقی | همه‌چیز، حتی هشدار امنیتی |
| خاموش‌بودن کانال | «با پیامک با من تماس نگیرید» | همه‌چیز روی آن کانال جز `critical` |
| `unsubscribed` | «تبلیغات برایم نفرست» | فقط تبلیغاتی |

نسخهٔ قبلی این پلتفرم هر سه را مطلق می‌گرفت. امن‌تر به نظر می‌رسید و در همان یک
جهتی که اهمیت دارد غلط بود: **کسی که کد یک‌بارمصرف نمی‌گیرد نمی‌تواند وارد شود**،
و هیچ‌چیز روی صفحه توضیح نمی‌داد چرا. خواندن لغو اشتراک تک‌کلیکی به‌معنی «هیچ
چیزی نفرست» یعنی هرکسی که تا حالا از یک خبرنامه لغو اشتراک کرده، از حساب خودش
بیرون بماند.

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

**توقف موقت به‌جای تعویق، دور می‌ریزد.** تعویق خوانش وسوسه‌انگیز است: توقف تاریخ
پایان دارد، پس چرا تایمر را دوباره کوک نکنیم؟ چون تعویق سی‌روزه یک پیشنهاد
سی‌روزه‌ٔ کهنه تحویل می‌دهد، و چون همهٔ پیام‌های متوقف‌شده در یک ماه شلوغ، روز
برداشته‌شدن توقف با هم می‌رسند. همان انفجار همان چیزی است که گیرنده از آن فرار
می‌کرد. **چیزی که بعد از توقف برمی‌گردد کمپین بعدی است، نه انباشت.**

توقف در **ساعت نه صبح به وقت محلی روز هدف** برداشته می‌شود، نه در ساعتی که
تنظیم شده. «سی روز متوقف کن» که ساعت ۲۳:۵۰ تنظیم شده و ۲۳:۵۰ برداشته شود، اولین
پیام برگشتی را نیمه‌شب می‌گذارد، که دقیق همان تجربه‌ای است که آدم برای فرار از آن
سراغ توقف رفته بود.

دو ویژگی دیگر برای هرکسی که کد اطراف این را می‌خواند مهم است:

- **رضایت روی پیام صف‌شده حمل نمی‌شود.** پیامی که یک شب منتظر مانده، قبل از رفتن
  دوباره آن را می‌خواند. سریالایز کردنش یعنی تحویل جوابی که کسی هشت ساعت پیش پسش
  گرفته است.
- **اجراکنندهٔ کمپین رضایت را صفحه‌به‌صفحه از پیش می‌خواند**، یک کوئری برای هر صفحه
  نه یکی برای هر گیرنده. شکست این پیش‌خوانی هیچ نگاشتی برنمی‌گرداند، که لایهٔ
  تصمیم آن را «پیش‌خوانی نشده» می‌خواند و به ازای هر گیرنده جستجو می‌کند. هیچ‌وقت
  «مجاز است» خوانده نمی‌شود. باز شکستن آنجا تنها باگی در این ناحیه بود که واقعا
  به کسی که لغو کرده پیام می‌داد.

## موضوع‌ها: «بس کن» را قابل تقسیم کردن {#topics}

پلتفرمی که فقط لغو اشتراک کلی دارد، هر شکایتی را، از «زیاد است» و «کانالش غلط
است» تا «به این یکی علاقه ندارم»، به یک جواب برگشت‌ناپذیر واحد تبدیل می‌کند، و
فهرست فقط کوچک‌تر می‌شود.

**موضوع** یعنی یکی از انواع پیامی که می‌فرستید، با اسمی که خودتان گذاشته‌اید، چون
گیرنده می‌خواندش.

| فیلد | معنی |
|---|---|
| `key` | نام ماشینی، `^[a-z0-9][a-z0-9_-]{0,63}$`. **بعد از ساخت قابل ویرایش نیست**، چون کمپین‌ها نگهش داشته‌اند. |
| `name` | چیزی که گیرنده می‌بیند. اجباری، حداکثر ۱۲۰ نویسه. |
| `description` | حداکثر ۵۰۰ نویسه |
| `channels` | رسانه‌هایی که این موضوع اصلا رویشان می‌رود. خالی یعنی همه. |
| `default_on` | `true` یعنی پیش‌فرض روشن، `false` یعنی پیش‌فرض خاموش. نبودنش یعنی `true`. |
| `position` و `archived` | ترتیب و بازنشستگی |

اینکه یک ارسال مجاز است یا نه به این ترتیب جواب داده می‌شود:

1. **شناسهٔ موضوع صفر همیشه مشترک است.** هر پیام تراکنشی، و هر کمپینی که قبل از
   وجود موضوع‌ها نوشته شده. «بدون موضوع» هیچ‌وقت نباید «مسدود» خوانده شود: آن
   خوانش، روزی که موضوع‌ها منتشر شدند هر کد یک‌بارمصرف روی پلتفرم را ساکت
   می‌کرد.
2. انتخاب صریح برنده است، در هر دو جهت. کسی که تیک جعبه‌ای را زده که پیش‌فرضش
   خاموش بوده مشترک می‌ماند، و تغییر بعدی پیش‌فرض نباید بی‌سروصدا بیرونش کند.
3. موضوع **بایگانی‌شده** بسته می‌شکند. گیرنده دیگر آن را روی صفحهٔ تنظیماتش
   نمی‌بیند، پس هیچ راهی برای متوقف کردنش ندارد.
4. موضوعی که روی این کانال فرستاده نمی‌شود بسته می‌شکند.
5. در غیر این صورت `default_on` خود موضوع جواب می‌دهد.
6. شناسهٔ موضوعی که به هیچ‌چیز نمی‌رسد بسته می‌شکند: یا سطر حذف‌شده است یا کمپینی
   که به موضوع حساب دیگری اشاره می‌کند، و هیچ‌کدام چیزی برای فرستادن نیست.

مدیریت موضوع‌ها و تغییر اشتراک یک آدم دو مجوز جدا دارند، عمدا:

```text
GET    /v1/preferences/topics                 settings.read
POST   /v1/preferences/topics                 settings.write
PATCH  /v1/preferences/topics/{id}            settings.write
DELETE /v1/preferences/topics/{id}            settings.write   (بایگانی، نه حذف)
POST   /v1/preferences/topics/{id}/restore    settings.write
POST   /v1/preferences/topics/reorder         settings.write

GET /v1/profiles/{user_id}/preferences        profile.read
PUT /v1/profiles/{user_id}/preferences        profile.write
```

مدیریت موضوع‌ها یک صفحهٔ تنظیمات است و هیچ‌چیز روی آن اسم یک آدم را نمی‌برد.
خواندن یا تغییر اشتراک یک نفر، پروندهٔ خود آن آدم است، و APIای که رویش می‌نویسد
می‌تواند یک مشتری را ساکت کند یا کسی را که لغو کرده دوباره مشترک کند.

کلید تکراری `409` است؛ هر شکست اعتبارسنجی دیگری `400`.

بدنهٔ `PUT /v1/profiles/{user_id}/preferences`:

```json
{
  "choices": [ { "topic_id": 4, "channel": "email", "subscribed": false } ],
  "unsubscribe": true,
  "pause_days": 30
}
```

هر فیلد اختیاری است و از `false` قابل تشخیص است، پس «گفته نشده» با «روی نادرست
گذاشته شده» فرق دارد. به‌روزرسانی جزئی‌ای که چون فراخوان یک فیلد را جا انداخته
بی‌سروصدا کسی را دوباره مشترک کند، بدترین باگ ممکن در این ناحیه بود. مقدار
`pause_days` حداکثر **۳۶۵** است؛ پنل و مرکز تنظیمات ۳۰ و ۶۰ و ۹۰ را پیشنهاد
می‌دهند.

**وقتی یک اپراتور تنظیمات کسی را عوض می‌کند، منبع به‌شکل `agent:<who>` ثبت
می‌شود، هیچ‌وقت `preference_center`.** درخواست انطباقی که نتواند این دو را از هم
تشخیص بدهد، درخواستی است که این پلتفرم نمی‌تواند جوابش را بدهد.

> [!danger]
> این مسیرها روی کنترل‌پلین‌اند، که در استقرار مرجع از اینترنت مسیردهی نشده
> است. **روی میزبان مدیریتی اصلا هیچ مسیر رضایت یا تنظیماتی وجود ندارد.**
> یکپارچه‌سازی‌ای که کلید `sk_seg_` دارد نمی‌تواند اشتراک کسی را بخواند یا تنظیم
> کند.

## سقف تعداد {#caps}

سقف یک شیء با شش دستگیره است، و همین یک ابزار بسته به تنظیمش به چند سؤال
متفاوت جواب می‌دهد.

| فیلد | معنی |
|---|---|
| `channel` | خالی یعنی همهٔ کانال‌ها با هم شمرده شوند، که راه جلوگیری از این است که یک نفر از پنج تیم مختلف پنج بار پیام بگیرد |
| `window` | پایین‌تر |
| `max` | سقف |
| `applies` | این سقف کدام دسته‌ها را اداره می‌کند. خالی یعنی فقط تبلیغاتی. |
| `scope` | شمارنده با چه کلیدی شمرده می‌شود |
| `budget` | این سقف کدام مخزن را می‌شمارد. خالی یعنی هر ارسالی. |

بازه‌ها:

| بازه | کی صفر می‌شود |
|---|---|
| `day` | نیمه‌شب محلی |
| `week` | **شنبه**، شروع هفتهٔ ایرانی |
| `month` | اول ماه **جلالی** |
| `10m` و `1h` و `24h` | لغزان |
| `interval:<duration>` | بازهٔ لغزان با هر طولی |

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

نام بازهٔ ناشناخته به‌جای رد شدن، روز تقویمی حساب می‌شود که تنگ‌ترین دورهٔ رایج
است.

دامنه‌ها:

| `scope` | چه چیزی را می‌شمارد |
|---|---|
| خالی | یک نفر. پیش‌فرض، و معنی «سقف تعداد» بدون توضیح اضافه. |
| `tenant` | کل حساب. جلوی یک سناریوی از کنترل خارج‌شده را می‌گیرد که مخاطب را اشباع می‌کند درحالی‌که همهٔ سقف‌های نفربه‌نفر رعایت شده‌اند. |
| `campaign` | کل خروجی یک کمپین. شعاع انفجار: اشتباهی که سر پنجاه هزار گرفته شود نه سر چهارصد هزار. |
| `recipient_campaign` | سابقهٔ یک نفر با یک کمپین، که همان چیزی است که «بیشتر از هفته‌ای یک بار نه» می‌پرسد |

آخری از دامنهٔ نفربه‌نفر جداست و همین تمایز کل ماجراست: سقف نفربه‌نفر بودجه‌ای
است که همهٔ چیزهایی که می‌خواهند به کسی برسند در آن شریک‌اند، و خرج کردنش برای
تنظیم فاصلهٔ یک کمپین تکرارشونده، همهٔ کمپین‌های دیگر را تا آخر روز ساکت
می‌کرد.

حساب تازه با ساعت سکوت ۲۳ تا ۹، منطقهٔ زمانی `Asia/Tehran` و سقف‌های **سه در
روز** و **ده در هفته** شروع می‌شود. این عمدا سخت‌گیرانه است: کسی که هیچ‌وقت این
صفحه را باز نمی‌کند هم نباید بتواند مخاطبش را بسوزاند.

**گرفتن جا اتمی است.** «جا هست؟ اگر هست حالا مال من است» یک رفت‌وبرگشت است.
شمردن و بعد افزودن، یک خواندن و یک نوشتن با فاصله‌ای بینشان است، و دو ورکر که به
یک نفر می‌فرستند با هم توی همان فاصله می‌افتند: هر دو «دو از سه» می‌خوانند، هر
دو می‌فرستند، و آن آدم چهار تا می‌گیرد. جا بلافاصله قبل از ترابری گرفته می‌شود و
وقتی چیزی تحویل داده نشد آزاد می‌شود، پس قطعی یک ارائه‌دهنده بودجهٔ روزانهٔ کسی
را خرج نمی‌کند.

**خستگی** سقف کسی را که دیگر تعامل نمی‌کند پایین می‌آورد و فقط می‌تواند پایین
بیاورد. پیش‌فرضش این است: بعد از **ده** پیام پشت سر هم بی‌واکنش، سقف در **نیم**
ضرب می‌شود، با کف **یک**. هیچ‌وقت صفر، چون سکوت کاری است که لغو اشتراک برایش
هست و سقفی که خودبه‌خود به صفر برسد آدم را بدون اینکه خودش خواسته باشد از دسترس
خارج می‌کند. کسی که هیچ‌وقت تعامل نکرده و تقریبا چیزی هم دریافت نکرده خسته
نیست: قاعده برای او تا دو برابر آستانه صبر می‌کند.

**بودجه** می‌گوید یک سقف کدام مخزن را می‌شمارد و به سؤالی جواب می‌دهد که هر حسابی
با بستهٔ پیامکی خریداری‌شده می‌پرسد: «این کمپین نباید از سهمیهٔ خبرنامه کم شود».
استفاده از آن به‌عنوان راه فرار ممکن نیست. ارسالی که بودجه‌ای همراهش نیست زیر
همهٔ سقف‌های بی‌بودجه است، و کف حفاظتی هم ذاتا بی‌بودجه است، پس نام بردن یک بودجه
فقط می‌تواند یک ارسال را زیر قاعده‌های **بیشتری** ببرد.

مسیر `PUT /v1/settings/governance` همهٔ بازه‌های جدول بالا را می‌پذیرد به‌جز
`interval:<duration>`، که آن را اجراکنندهٔ کمپین از فاصلهٔ حداقلی خود کمپین
می‌سازد و در سیاست نوشته نمی‌شود. سقفی با `max` کمتر از یک رد می‌شود: صفر یعنی
«هیچ پیامی»، که کار خاموش کردن حکمرانی است، و پذیرفتنش یعنی دو راه برای بیان یک
حالت داشته باشیم و هیچ راهی برای فهمیدن اینکه کدامش منظور بوده.

دو سقف وقتی یکی حساب می‌شوند که `scope` و `channel` و `window` و `budget` هر
چهارتا یکی باشند. سقف روزانهٔ هر نفر و سقف روزانهٔ کل حساب دو شمارندهٔ متفاوت‌اند
و هر دو با هم قابل تنظیم‌اند.

## سقف نرخ ارسال حساب {#rate-ceiling}

سقفی با `scope: "tenant"` و بازهٔ کوتاه، سقف نرخ است: نه اینکه هر نفر چند پیام
می‌گیرد، بلکه کل حساب با چه سرعتی می‌فرستد.

```json
{
  "scope": "tenant",
  "window": "1h",
  "max": 10000,
  "applies": ["marketing", "transactional"]
}
```

**این سقف پیام را معوق می‌کند نه دور می‌اندازد، و تنها سقفی است که چنین می‌کند.**
بقیهٔ سقف‌ها ارسال را دور می‌اندازند: کسی که سهم امروزش تمام شده نباید تا صبح نگه
داشته شود، چون این یعنی شب را روی ساعت ۹ تلنبار کرده‌ایم و دقیقاً همان برستی
ساخته می‌شود که سقف جلویش را می‌گرفت. سقف نرخ سؤال برعکسی می‌پرسد، و پخش کردن
ارسال روی زمان تمام کاری است که از آن خواسته شده. کمپین دو میلیونی در برابر سقف
ده هزار در ساعت به همه می‌رسد، در حدود هشت روز، نه اینکه به ده هزار نفر اول
برسد و بقیه را دور بیندازد.

پیام معوق با دلیل `rate_limited` ثبت می‌شود و لحظهٔ تلاش بعدی را همراه دارد:
مرز بعدی برای بازهٔ تقویمی، و یک بازهٔ کامل از حالا برای بازهٔ لغزان. آزاد شدن
روی حداکثر یک‌دهم بازه و حداکثر پنج دقیقه پخش می‌شود تا پیام‌های یک بازه همه در
یک لحظه از نو راه نیفتند. این جابه‌جایی از خود گیرنده مشتق می‌شود نه تصادفی، پس
پیامی که چند بار تلاش شود روی همان لحظه می‌نشیند و با هر تلاش عقب‌تر نمی‌رود.

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

> [!warn]
> **`applies` پیش‌فرض خالی است، و خالی یعنی فقط تبلیغاتی.** سقف نرخی که بدون آن
> نوشته شود، یک کمپین تبلیغاتی را می‌شمارد و هر ارسال تراکنشی را نشمرده رد
> می‌کند، در حالی که همان عددی را که گذاشته‌اید نشان می‌دهد. طبقه‌ها را صریح
> بنویسید. پنل همین کار را می‌کند.

هرچه `applies` بگوید، `critical` هیچ‌وقت مشمول نمی‌شود. برای اینکه چه چیزی آنجا
می‌نشیند [طبقه‌ها](/docs/transactional#category) را ببینید، و توجه کنید که
**کد ورود بحرانی است نه تراکنشی**، دقیقاً برای اینکه یک کمپین تبلیغاتی که ساعت
حساب را پر کرده نتواند کدی را که کسی برای ورود منتظرش است نگه دارد.

## ساعت سکوت {#quiet-hours}

| فیلد | معنی |
|---|---|
| `start_hour` و `end_hour` | به **وقت محلی خود گیرنده**، و وقتی شروع بعد از پایان باشد دور نیمه‌شب می‌پیچد |
| `skip_weekend` | تبلیغات را در آخر هفتهٔ ایران هم ساکت می‌کند، که **جمعه** است |
| `policy` | `defer` تا پایان بازه نگه می‌دارد، `drop` دور می‌ریزد |
| `release_spread_minutes` | پیام‌های نگه‌داشته‌شده در چه پهنایی آزاد شوند. صفر یعنی پانزده دقیقه. |

**پیش‌فرض ۲۳ تا ۹ است، نه ۲۲ تا ۸ که بیشتر پلتفرم‌ها می‌فرستند.** تهران دیر
می‌خوابد: مغازه ساعت ۲۲ باز است و روز کاری ساعت نه شروع می‌شود. ساکت کردن از ده
شب یعنی دور انداختن پرواکنش‌ترین ساعت شب.

مقدار `drop` فقط برای محتوایی درست است که ارزشش تا صبح از بین می‌رود، مثل حراج
دوساعته. بقیه به تعویق می‌افتند، چون پیشنهاد ساعت نه صبح هم هنوز برقرار است.

**پیام‌های به‌تعویق‌افتاده در یک پنجرهٔ پخش آزاد می‌شوند**، پیش‌فرض پانزده دقیقه،
با جابه‌جایی‌ای که از هش پایدار شناسهٔ کاربر می‌آید. بدون آن، هر پیامی که بین ۲۳
و ۹ نگه داشته شده یک لحظهٔ آزادسازی یکسان دارد، پس کل ترافیک شب در همان یک ثانیه
به درگاه‌ها تحویل می‌شود: API خودتان جهشی می‌خورد که در طول روز هیچ‌وقت نمی‌بیند،
ارائه‌دهنده سقف نرخ می‌گذارد، و تلاش‌های مجدد هم با هم می‌رسند. جابه‌جایی به‌جای
تصادفی از هش می‌آید تا پیامی که به ۹:۰۴ افتاده و دوباره تلاش شده، روی هر تلاش
دیرتر و دیرتر نرود.

**پنجرهٔ اختصاصی هر کانال** پیش‌فرض را برای یک کانال کنار می‌زند، و پنجرهٔ
کانال‌محور به‌جای ادغام، کامل برنده می‌شود. پیامک ساعت ۲۳:۳۰ گوشی را روی میز
کنار تخت بیدار می‌کند؛ پیام درون‌برنامه‌ای داخل اپی منتظر می‌ماند که کسی بازش
نکرده، و ساکت کردنش یک ارسال را بدون هیچ فایده‌ای برای کسی خرج می‌کند. **پنجرهٔ
با پهنای صفر، یعنی شروع برابر پایان، راه گفتن «این کانال هیچ‌وقت ساکت نیست»
است.** حذف کردن آن سطر به‌جایش به پیش‌فرض برمی‌گردد، که برعکس منظور شماست.

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

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

## کفی که نمی‌توانید خاموشش کنید {#floor}

هرچه در سیاست هست مال شماست که تنظیمش کنید. این یکی نیست.

```text
ساعت سکوت      22:00 تا 08:00
سقف            10 در روز، 30 در هفته
منطقه زمانی    Asia/Tehran
```

**این کف یک ثابت در کد است، نه سطری در حساب شما.** کفی که کنار سیاستی ذخیره شود
که قرار است محدودش کند، کفی است که یک نفر با همان صفحه، همان توکن API و همان
اشتباه می‌تواند ویرایشش کند. بالا بردنش یک استقرار است، و نکته همین است: باید
تصمیم ما باشد نه یک اهرم در داشبورد.

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

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

**خاموش کردن حکمرانی کف را برنمی‌دارد.** مقدار `enabled: false` قاعده‌های خود
شما را برمی‌دارد و کف را سر جایش می‌گذارد. این کلید یعنی «قاعده‌های خودم را
موقتا کنار بگذار» که خواستهٔ معقولی برای یک اپراتور است. چیزی که نمی‌تواند
به‌معنی آن باشد این است که حالا می‌شود به آدم آن سر خط ساعت سه بامداد چهارصد بار
پیام داد.

جدا از کف، پلتفرم **پنجرهٔ قانونی ارسال تجاری** را هم به‌شکل داده نگه می‌دارد:
سکوت از ۲۲ تا ۸، یعنی متمم ۸ تا ۲۲. این یک خوانش محافظه‌کارانه است. منبع مرجع
برای پنجرهٔ تجاری چیزی نبود که بتوانیم قطعی‌اش کنیم، و راهنماهای منتشرشده ۸ تا
۲۲ یا ۸ تا ۲۳ می‌گویند؛ تنگ‌تر همان است که آدم ترجیح می‌دهد در جهت امن اشتباه
کند. به‌شکل داده نگه داشته شده نه استنتاج‌شده از کف ساعت سکوت، تا وقتی کسی
قطعی‌اش کرد، چیزی که عوض می‌شود یک عدد باشد نه شکل چیزی.

## مرکز تنظیمات گیرنده {#preference-centre}

صفحهٔ خود گیرنده. میزبان ورود داده آن را به‌شکل دو نقطهٔ HTTP ساده و یک صفحهٔ
HTML خودبسنده سرو می‌کند. **نه API جیسون، نه باندل، نه جاوااسکریپت.**

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

```text
GET  https://in.segmentic.net/e/p   رندر صفحه
POST https://in.segmentic.net/e/p   اعمال فرم
GET  https://in.segmentic.net/e/u   صفحهٔ تایید لغو اشتراک
POST https://in.segmentic.net/e/u   خود لغو اشتراک تک‌کلیکی
```

هر دو `sg_mid` (شناسهٔ پیام) و `sg_t` (توکن امضاشده) را حمل می‌کنند. هویت با
راستی‌آزمایی توکن در برابر شناسهٔ پیام اثبات می‌شود. **امضای بد و پیام ناشناخته
دقیق یک جواب می‌گیرند**، تا کسی نتواند بفهمد چه شناسه‌های پیامی وجود دارند.

فرم این‌ها را پیشنهاد می‌دهد: `save` و `unsubscribe_all` و `resubscribe` و
`pause` (با `pause_days`) و `resume`. اقدام `unsubscribe_all` هر توقف موقتی را
هم صریح پاک می‌کند، چون کسی که وسط یک توقف لغو اشتراک می‌کند حرف قوی‌تر را زده و
جا گذاشتن توقف زیر آن، بعدا بی‌سروصدا به «دوباره مشترک شد» منقضی می‌شد.

دو جزئیات که به‌راحتی اشتباه می‌شوند و کل استدلال درستی همین‌هاست:

- تحلیل‌گر فرم روی **جعبه‌هایی که روی صفحه بودند** می‌چرخد، نه روی فیلدهایی که
  رسیده‌اند، و برای هر تیک یک فیلد پنهان همراه دارد. تیک زده‌نشده اصلا در بدنهٔ
  POST نیست، پس خواندن فقط آنچه رسیده، «همه را برداشتم» را از «هیچی را عوض
  نکردم» غیرقابل تشخیص می‌کرد، و گیرنده ذخیره را می‌زد و می‌دید هیچ اتفاقی
  نمی‌افتد.
- بعد از ذخیره، وضعیت **دوباره خوانده می‌شود نه بازتاب داده**. این دو هرجا که
  چیزی رد شده باشد فرق دارند، مثل موضوع ناشناخته یا کانالی که موضوع رویش
  فرستاده نمی‌شود، و پس دادن همان فرم به گیرنده می‌گفت تغییری اعمال شده که نشده.
  تنها ارزش این صفحه این است که باورش می‌کنند.

موضوعی که فهرست کانال ندارد سه جعبه نشان می‌دهد، ایمیل و پیامک و اعلان، نه یکی
به ازای هر کانال. **کانالی که آدم خاموشش کرده غیرفعال و برچسب‌خورده نشان داده
می‌شود، نه پنهان**، چون کسی که تعجب می‌کند چرا خبرنامه قطع شده باید ببیند که خود
ایمیل خاموش است.

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

صفحه‌ها با `no-store` و `noindex, nofollow` و `Referrer-Policy: no-referrer`
سرو می‌شوند. این صفحه تنظیمات اشتراک یک نفر را دارد و با یک پیوند حامل باز
می‌شود، پس سیاست ارجاع‌دهنده جلوی نشت آن پیوند، با امضا و همه‌چیزش، به هر
میزبانی که یک تصویر یا یک کلیک به آن می‌رسد را می‌گیرد.

## لغو اشتراک با GET، لغو نمی‌کند {#unsubscribe}

مسیر `GET /e/u` صفحه‌ای با یک دکمه رندر می‌کند. هیچ‌کس را لغو نمی‌کند. این
مهم‌ترین بند این صفحه است.

اسکنرهای امنیتی بیشتر بانک‌ها و خرده‌فروشی‌های بزرگ ایران، هر پیوند هر پیام
ورودی را قبل از اینکه گیرنده اصلا ببیندش دنبال می‌کنند. یک GET که آدم‌ها را لغو
کند، همان لحظه‌ای که کمپین می‌رسد کل فهرست یک شرکت را بی‌سروصدا لغو اشتراک
می‌کرد، و اولین نشانه‌اش برای مشتری، گزارش پوششی بود که فرو ریخته است.

مسیر `POST /e/u` همان مسیر تک‌کلیکی `RFC 8058` است. چیزی است که جی‌میل و بقیه
وقتی گیرنده دکمهٔ Unsubscribe کنار نام فرستنده را می‌زند صدا می‌زنند. **آنجا
عمدا هیچ مرحلهٔ تاییدی نیست:** ارائه‌دهندگان صندوق پستی که حالا این هدر را
می‌خواهند، صفحهٔ فرود را عدم انطباق حساب می‌کنند. پس دو فعل روی یک مسیر، دو
محصول متفاوت‌اند و هرکدام در جای آن یکی غلط است.

**لغو تک‌کلیکی یک لغو کلی است نه کانالی**، هرچند توکن، کانالی را که پیام از آن
رفته نام می‌برد. چیزی که آدمی که آن دکمه را می‌زند منظور دارد «بس کن» است.
خواندنش به‌معنی «ایمیل نفرست، ولی پیامک و پیام بله ادامه پیدا کند» خوانشی است که
هم از نظر حقوقی ضعیف‌تر است و هم برای گیرنده از نادیده گرفتنش قابل تشخیص نیست.
کم‌لغو‌کردن ریسک انطباق است و زیاد‌لغو‌کردن ریسک بازاریابی، و فقط یکی از این دو
قرارداد را تمام می‌کند.

**آن POST حتی وقتی خرابی از سمت ماست `200` جواب می‌دهد.** ارائه‌دهنده‌ای که
اینجا خطا ببیند ممکن است پیام را به‌عنوان «لغو اشتراکش خراب است» نشان بدهد، و
تلاش‌های مجدد از زیرساخت یک ارائه‌دهنده چیزی نیست که ما بتوانیم شکلش بدهیم. بدنه
می‌گوید چه شد؛ کد وضعیت می‌گوید ارائه‌دهنده نگران نباشد.

روی ایمیل، پیوندها را چیدمان اضافه می‌کند نه قالب شما:

- هدرهای `List-Unsubscribe: <...>` و
  `List-Unsubscribe-Post: List-Unsubscribe=One-Click` روی هر پیامی که نشانی لغو
  اشتراک دارد گذاشته می‌شوند. از سال ۲۰۲۴ جی‌میل و یاهو روی ایمیل انبوه یکی از
  این‌ها را لازم دارند، و فرستنده‌ای که ندارد تحویل کل دامنه‌اش افت می‌کند، نه فقط
  همان پیامی که جا انداخته.
- پیوند دیدنی لغو اشتراک در پانویس می‌رود. پیوند مرکز تنظیمات **کنارش می‌رود،
  هیچ‌وقت به‌جایش.** پیشنهاد دادن فقط صفحهٔ تنظیمات به کسی که تصمیم گرفته برود،
  الگویی است که هر ارائه‌دهندهٔ صندوق پستی آن را تاریک حساب می‌کند، و جواب گیرنده
  به آن دکمهٔ اسپم است.
- پیوندهای خودتان برای رهگیری کلیک **قبل از** پیچیده شدن پوسته دور پیام
  بازنویسی می‌شوند، تا پیوند لغو اشتراکی که چیدمان بعدا اضافه می‌کند دست‌نخورده
  بماند. گیرنده باید همیشه یک کلیک با رفتن فاصله داشته باشد، بدون هیچ‌چیز ما در
  مسیر.

## جواب دادن به پیامک {#sms-reply}

پیامک تبلیغاتی ایرانی، دستور لغو `لغو۱۱` را به انتهای متنش می‌گیرد. یک بار
اضافه می‌شود: اگر متن از قبل `لغو` داشته باشد پانویس رد می‌شود. **متن تراکنشی
هیچ‌وقت آن را نمی‌گیرد**، چون خط خدماتی الگویی را حمل می‌کند که اپراتور تایید
کرده و اضافه کردن به آن باعث می‌شود دیگر با الگو نخواند. بخش
[پیام تراکنشی](/docs/transactional#sms-patterns) را ببینید.

صورتحساب متن را **با** پانویس می‌شمارد. شمارش بخش در آهنگساز **بدون** آن گرفته
می‌شود، پس یک پیامک تبلیغاتی می‌تواند یک بخشی اعلام و دوبخشی فاکتور شود.

جواب‌های ورودی **بعد از یکسان‌سازی، دقیق مقایسه می‌شوند و هیچ‌وقت به‌شکل زیررشته**،
پس پیامی که اتفاقا یک کلمهٔ توقف را وسط جمله دارد کسی را لغو نمی‌کند.

| معنی | کلمه‌ها |
|---|---|
| لغو | `لغو` و `لغو11` و `لغو ۱۱` و `لغو۱۱` و `11` و `۱۱` و `1` و `۱` و `off` و `stop` و `cancel` و `unsubscribe` و `end` و `قطع` و `توقف` و `نمیخوام` و `نمی‌خوام` |
| عضویت | `شروع` و `عضویت` و `بله` و `start` و `on` و `yes` و `subscribe` و `22` و `۲۲` |

**پیامک یک فهرست لغو دوم دارد که با شمارهٔ تلفن کلید می‌خورد**، جدا از رضایتی که
با پرونده کلید می‌خورد، چون جواب از گوشی‌ای می‌آید که شاید با هیچ پرونده‌ای
نخواند. فقط برای تبلیغاتی بررسی می‌شود، و **این جستجو بسته می‌شکند**: اگر خوانده
نشود، پیامک تبلیغاتی نمی‌رود.

## همهٔ کدهای دلیل {#reasons}

هرکدام روی پیام ذخیره می‌شوند و سمت سرور به یک جمله تبدیل می‌شوند، تا API و
خروجی CSV و گزارش پوشش کمپین همگی یک حرف را بزنند.

| کد | معنی |
|---|---|
| `unsubscribed` | لغو کلی. فقط تبلیغاتی. |
| `suppressed` | کنارگذاشتن اپراتوری. همه‌چیز را می‌بندد. |
| `channel_opt_out` | این رسانه خاموش است |
| `topic_opt_out` | حکم مرکز تنظیمات. از لغو اشتراک جدا نگه داشته شده چون در هر گزارشی که بعدش می‌آید این دو واقعیت متضادند: یکی فهرستی است که هنوز کار می‌کند و دیگری فهرستی که نمی‌کند. |
| `paused` | توقف خودخواسته با تاریخ پایان |
| `frequency_cap` | از سقف رد شده |
| `rate_limited` | از سقف نرخ حساب رد شده. تنها دلیل سقفی که به‌جای دور انداختن، معوق می‌کند: پیام لحظهٔ تلاش بعدی را همراه دارد. جدا از `frequency_cap` نام دارد چون این دو خواننده را به دو عدد متفاوت می‌فرستند، و بالا بردن سقف هر نفر برای رفع سقف نرخ، چیزی را عوض می‌کند که هر گیرنده‌ای دریافت می‌کند. |
| `too_soon` | از قاعدهٔ فاصلهٔ خود کمپین رد شده. از سقف تعداد جداست چون این دو تصمیم دو آدم دربارهٔ دو چیز متفاوت‌اند. |
| `quiet_hours` | داخل پنجرهٔ سکوت |
| `weekend` | قاعدهٔ آخر هفتهٔ ایران |
| `holdout` | گروه کنترل یک کمپین یا سناریو |
| `global_holdout` | گروه کنترل کلی حساب |
| `not_reachable` | نه دستگاهی، نه شماره‌ای، نه گفتگویی |
| `campaign_paused` | کمپین متوقف شده |
| `recalled` | صف شده و قبل از رفتن لغو شده. پیام موقع ساخته شدن درست بوده و تا وقتی می‌رسید غلط شده بود. |
| `switched_off` | یک کلید قطع |
| `expired` | `valid_until` گذشته. هیچ‌چیز خراب نشده؛ گزارشی که آن را شکست نشان بدهد کسی را دنبالش می‌فرستاد. |
| `arbitrated` | جایش را به پیام مهم‌تری باخته و کانال ارزان‌تری برای بازگشت نداشته |
| `fatigued` | با سقف کاهش‌یافتهٔ کسی که دیگر تعامل نمی‌کند متوقف شده |
| `missing_personalisation` | متغیر قالب نه مقدار داشت نه جایگزین |
| `empty_content` | رندر اصلا چیزی تولید نکرد |
| `dry_run` | تمرین: همه‌چیز اجرا شد جز ترابری |
| `dry_run_no_channel` | تمرین روی کانالی که پیکربندی نشده |
| `policy_unavailable` | سیاست خوانده نشد |
| `consent_unavailable` | رضایت خوانده نشد |
| `topics_unavailable` | موضوع‌ها خوانده نشدند |
| `counters_unavailable` | شمارنده‌ها خوانده نشدند |

چهار تای آخر یادداشت خودشان را می‌خواهند. قبلا با چسباندن رشته ساخته می‌شدند، که
مقداری بیرون از مجموعهٔ شناخته‌شده را وارد هر گزارشی می‌کرد که رندرش می‌کرد؛
داشبورد فارسی برای هرچه نشناسد به خود کد خام برمی‌گردد، پس یک بازاریاب وسط یک
اختلال ردیس روی صفحه‌اش `counters_unavailable` خواند. حالا ثابت‌اند.

## هرکدام به کدام جهت می‌شکند {#failure-direction}

بعضی چیزها باید موقع خرابی باز بمانند و بعضی بسته، و انتخاب اشتباه در هر جهت یک
حادثه است.

| سازوکار | موقع خرابی | چرا |
|---|---|---|
| جستجوی سیاست و رضایت و موضوع و شمارنده | **بسته** | اگر ردیس خوابیده باشد نمی‌توانیم بگوییم کسی امروز سه پیامش را گرفته یا نه، و حدس زدن «نگرفته» وسط قطعی، راهی است که یک مخاطب کامل دو بار کمپین می‌گیرد. نفرستادن برگشت‌پذیر است؛ فرستادن نیست. |
| کلید قطع کانال و حساب | **باز** | بسته شدنش یعنی یک قطعی ردیس کل نصب را ساکت می‌کند، از جمله کدهای ورودش. |
| جستجوی فراخوانی برگشت | **باز** | فراخوانی برگشت فقط چیزی را لغو می‌کند که از قبل مجاز بوده، پس قطعی به‌جای تصمیم رضایت کسی، یک لغو را هزینه می‌کند. |
| فهرست لغو پیامکی با کلید شماره | **بسته** | این یک تصمیم رضایت واقعی است که بیرون از این سامانه گرفته شده. |
| حالت آزمایشی | **بسته** | حسابی که گفته نفرست و به آدم واقعی برسد، برگشت‌پذیر نیست. |
| سقف نرخ تراکنشی | **باز** | آن نقطهٔ ورود رسید سفارش و کد ورود حمل می‌کند. |
| بودجهٔ درخواست میزبان مدیریتی | **بسته** | گزارش و کوئری مخاطب حمل می‌کند. چک‌اوت کسی به‌خاطر منتظر ماندن یک گزارش نمی‌شکند. |
| قاعده‌های تایید کمپین | **بسته** | |

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

**فراخوانی برگشت فقط روی پیامی اعمال می‌شود که واقعا منتظر مانده باشد.** ارسالی
که در یک پاس تصمیم گرفته و تحویل داده شده پنجره‌ای برای برگشت خوردن ندارد، و
پرداخت هزینهٔ آن جستجو روی تک‌تکشان یعنی گذاشتن یک رفت‌وبرگشت روی مسیر داغ برای
جواب دادن به سؤالی که نمی‌تواند «بله» باشد. متوقف کردن یا لغو یک کمپین برای
ارسال‌هایی که از قبل صف کرده یک برگشت صادر می‌کند، در حد توان و هیچ‌وقت کشنده.

**حالت آزمایشی بعد از همه‌چیز بررسی می‌شود**، بعد از رضایت و سقف و ساعت سکوت و
کلیدها و قالب و شخصی‌سازی، و فقط ترابری رد می‌شود. تمرین با `is_test` و دلیل
`dry_run` ثبت می‌شود، پس هیچ‌وقت نمی‌تواند در گزارش پوشش شمرده شود.

## چیزی که وجود ندارد {#not-built}

- **روی میزبان مدیریتی هیچ مسیر رضایت یا تنظیماتی نیست.** یکپارچه‌سازی نمی‌تواند
  اشتراک کسی را بخواند یا تنظیم کند، نمی‌تواند کسی را لغو کند و نمی‌تواند
  موضوع‌ها را فهرست کند. مسیرهای بالا روی کنترل‌پلین‌اند که استقرار مرجع منتشرش
  نمی‌کند.
- **هیچ‌چیز در کد منتشرشده `suppressed` را ست نمی‌کند.** این حالت روی هر ارسال
  خوانده می‌شود و همه‌چیز حتی پیام حیاتی را می‌بندد، متد ذخیره‌اش وجود دارد، و
  هیچ فراخوانی ندارد: نه مسیری، نه صفحه‌ای در پنل، نه ورکری. امروز این یک نوشتن
  دستی در دیتابیس است.
- **هیچ‌چیز جز برگشت دائمی یا شکایت اسپم با نشانی پنهان، کانالی را خاموش
  نمی‌کند.** سرویس تحویل‌پذیری فقط روی گزارشی کار می‌کند که ارائه‌دهنده نشانی
  گیرنده را در آن پنهان کرده است: آدم را از شناسهٔ پیام داخل `message_log` پیدا
  می‌کند و کانال ایمیل را خاموش می‌کند، هیچ‌وقت کانال دیگری را. گزارشی که خود
  نشانی را می‌آورد از مسیر دیگری می‌رود: برگشت دائمی و شکایت اسپم و برگشت موقتی
  که تکرار شود، به‌جایش یک سطر `email_suppressions` می‌نویسند، که خاموش کردن
  کانال نیست. APIای برایش نیست، و مرکز تنظیمات کانال خاموش را غیرفعال نشان
  می‌دهد بدون اینکه راهی برای روشن کردنش بدهد. آدمی که می‌خواهد پیامک خاموش
  باشد، مسیر خودخدمتی به خاموش کردن کانال ندارد. جواب `لغو` واقعا پیامک
  تبلیغاتی را متوقف می‌کند، ولی روی همان فهرست جدا با کلید شماره در بخش
  [جواب دادن به پیامک](/docs/consent#sms-reply) می‌نشیند نه روی پرونده، و هیچ
  برگشت و شکایتی هم خاموشی کانال نمی‌سازد، چون آن نویسنده فقط ایمیل را دست
  می‌زند.
- **سقف با بازهٔ لغزان از API قابل تنظیم نیست.** بخش
  [سقف تعداد](/docs/consent#caps) را ببینید.
- **سقف تعداد به‌تفکیک موضوع وجود ندارد.** سقف با کانال یا حساب یا کمپین یا
  گیرنده کلید می‌خورد، هیچ‌وقت با موضوع.
- **ارسال تراکنشی `valid_until` ندارد.** قاعدهٔ انقضا در لایهٔ تصمیم هست و
  `POST /v1/messages` فیلدی برایش ندارد. بخش
  [پیام تراکنشی](/docs/transactional#request) را ببینید.
- **پیش‌نمایشی از اینکه یک تغییر سیاست چه چیزی را می‌بست وجود ندارد.** بریدن به
  کف می‌گوید قاعده‌ها چه خواهند بود؛ هیچ‌چیز نمی‌گوید از پیام‌های ماه گذشته چند
  تا را متوقف می‌کردند.

---

# پیام درون‌برنامه‌ای و صندوق پیام

> بنر، مودال و نظرسنجی داخل سایت و اپ، و صندوقی که پیام‌ها در آن می‌مانند.
> https://segmentic.net/docs/onsite

## پیام درون‌سایت چیست {#what-this-is}

> نمودار: تبدیل بازدید صفحه، ورود به سگمنت و رویداد به بنر، مودال و نظرسنجی مجاز

قاعده‌های نمایش روی سرور ما ارزیابی نمی‌شوند. در مرورگر بازدیدکننده ارزیابی می‌شوند.

دلیلش حساب ساده‌ای است. اگر هر بازدید صفحه یک درخواست به ما بزند، یک فروشگاه متوسط ایرانی روزی یک میلیون درخواست به ما می‌فرستد، آن هم روی مسیر رندر صفحه‌ی خودش: تأخیر ما جلوی محتوای آن‌ها می‌نشیند و در دسترس بودن ما جلوی کسب‌وکارشان. به‌جای آن، SDK یک بار در دقیقه فهرست کمپین‌های زنده را می‌گیرد و تطبیق را همان‌جا انجام می‌دهد.

هزینه‌ی این طراحی یک چیز است و باید بدانید: **قاعده‌های هدف‌گیری عمومی‌اند.** هر کسی می‌تواند تب Network را باز کند و ببیند شما چه کسی را با چه شرطی هدف گرفته‌اید. هیچ چیز محرمانه‌ای نباید داخل یک قاعده‌ی هدف‌گیری برود.

سه نقطه‌ی پایانی روی collector هستند، با کلید نوشتن `wk_seg_...`:

| متد و مسیر | کار |
| --- | --- |
| `GET /v1/onsite` | فهرست کمپین‌های زنده |
| `POST /v1/onsite/event` | ثبت نمایش، کلیک، بستن یا تبدیل |
| `POST /v1/onsite/response` | ثبت پاسخ نظرسنجی |

هر سه فقط وقتی ثبت می‌شوند که نصب شما یک انبار on-site داشته باشد. اگر نداشته باشد، پاسخ `405 Method Not Allowed` است و نه `404`، چون الگوی `OPTIONS /v1/` که برای CORS ثبت شده تمام پیشوند `/v1/` را برمی‌دارد.

> [!note]
> کلید نوشتن عمومی است و داخل کد سایت شما می‌نشیند. کلید API با پیشوند `sk_seg_` روی `https://api.segmentic.net` است و هرگز نباید به مرورگر برسد. نمایش دادن کمپین با کلید نوشتن انجام می‌شود. ساختن و منتشر کردنش با هیچ‌کدام از این دو انجام نمی‌شود: آن مسیرها روی کنترل‌پلین‌اند و در پنل و با نشست خود آدم به آن‌ها می‌رسید، همان‌طور که در [ساختن و منتشر کردن کمپین](/docs/onsite#managing) آمده.

## گرفتن فهرست کمپین‌ها {#fetch}

`GET https://in.segmentic.net/v1/onsite`. هیچ پارامتری جز خود کلید ندارد.

کلید از سه جا خوانده می‌شود، به همین ترتیب: هدر `Authorization: Bearer`، هدر `X-Segmentic-Key`، یا پارامتر پرس‌وجوی `write_key`. SDK وب برای این GET پارامتر پرس‌وجو را به کار می‌برد، چون آن‌وقت درخواست یک درخواست ساده است، هیچ preflight ندارد و یک CDN که روی هدر `Authorization` تنوع نمی‌دهد می‌تواند پاسخ را کش کند.

```bash title="گرفتن کمپین‌های زنده"
curl -s "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
```

```json title="پاسخ ۲۰۰"
{
  "campaigns": [
    {
      "id": 7,
      "name": "تخفیف عید",
      "kind": "modal",
      "status": "live",
      "content": {
        "headline": "۱۵ درصد تخفیف تا پایان هفته",
        "body": "کد را در سبد خرید وارد کنید.",
        "button_text": "دیدن محصولات",
        "button_url": "/products",
        "accent": "#2563eb"
      },
      "targeting": {
        "url_contains": ["/products"],
        "devices": ["desktop"],
        "delay_seconds": 5
      },
      "max_impressions": 3,
      "cooldown_hours": 24,
      "dismissible": true,
      "starts_at": "2026-08-01T00:00:00Z",
      "ends_at": "2026-08-14T00:00:00Z",
      "impressions": 1842,
      "clicks": 96,
      "dismissals": 311,
      "created_by": "ملیکا",
      "created_at": "2026-07-28T09:12:44Z",
      "updated_at": "2026-07-30T11:02:10Z"
    }
  ],
  "cache_seconds": 60
}
```

پاسخ هدر `Cache-Control: public, max-age=60` دارد و `cache_seconds` همان عدد را تکرار می‌کند تا مرورگر بدون پرسیدن، همان حساب سقف نمایش را از حافظه‌ی محلی انجام بدهد. فهرست سمت سرور با `status = live` و پنجره‌ی تاریخ فیلتر شده است.

اگر انبار ما از کار بیفتد پاسخ باز هم `200` است، با `{"campaigns": []}` و بدون `cache_seconds` و بدون هدر کش. این کد داخل بارگذاری صفحه‌ی مشتری می‌دود، پس خرابی ما باید به «امروز بنری نیست» تنزل کند و نه به یک خطا در کنسول سایت آن‌ها.

خطاهای احراز هویت:

| وضعیت | کد | بدنه |
| --- | --- | --- |
| کلید فرستاده نشده | `401` | `{"status":"error","message":"missing write key"}` |
| کلید ناشناس، باطل یا معلق | `401` | `{"status":"error","message":"invalid write key"}` |
| جست‌وجوی کلید خودش شکست خورد | `503` با `Retry-After: 5` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |

سطر سوم عمدی است. یک SDK کد `401` را «این کلید هیچ‌وقت کار نمی‌کند» می‌خواند، متوقف می‌شود و رویدادها را دور می‌ریزد؛ `503` را «بعدا دوباره امتحان کن» می‌خواند و نگهشان می‌دارد.

CORS روی هر سه نقطه‌ی پایانی: `Access-Control-Allow-Origin: *`، متدهای `POST, OPTIONS`، هدرهای `Content-Type, Authorization, X-Segmentic-Key`، و `Access-Control-Max-Age: 86400`.

### شکل کمپین روی سیم {#campaign-shape}

| فیلد | نوع | توضیح |
| --- | --- | --- |
| `id` | `int64` | |
| `name` | string | نام داخلی، در پنل دیده می‌شود |
| `kind` | string | `banner`، `modal`، `slidein` یا `survey` |
| `status` | string | `draft`، `live`، `paused` یا `ended` |
| `content` | object | زیر همین بخش |
| `targeting` | object | بخش [ارزیابی قاعده‌ها](/docs/onsite#targeting) |
| `max_impressions` | int | صفر یعنی بی‌سقف |
| `cooldown_hours` | int | صفر یعنی بدون فاصله |
| `dismissible` | bool | |
| `starts_at` | `RFC3339`، اختیاری | |
| `ends_at` | `RFC3339`، اختیاری | |
| `impressions` | `int64` | همیشه هست |
| `clicks` | `int64` | همیشه هست |
| `dismissals` | `int64` | همیشه هست |
| `created_by` | string، اختیاری | نام نمایشی کاربری که ذخیره کرده، یا `apikey:<id>` |
| `created_at` | زمان، اختیاری | |
| `updated_at` | زمان، اختیاری | |

فیلدهای `content` که همگی اختیاری‌اند: `headline`، `body`، `image_url`، `button_text`، `button_url`، `position`، `background`، `text_color`، `accent`، `question`، `nps` (bool)، `follow_up`، `choices` (آرایه‌ی رشته)، `thank_you`.

`position` برای بنر یعنی بالا یا پایین، و برای پیام گوشه‌ای یعنی کدام گوشه.

### آنچه این پاسخ فاش می‌کند {#what-the-response-exposes}

این پاسخ کل ساختار کمپین است، نه نسخه‌ای خلاصه‌شده. یعنی `impressions`، `clicks`، `dismissals`، `created_by`، `created_at` و `name` هم در آن هستند و هر بازدیدکننده‌ی سایت شما می‌تواند در تب Network آن‌ها را بخواند. تنها چیزی که حذف شده شناسه‌ی حساب است.

اگر نام واقعی همکارتان در `created_by` نشستن برایتان مسئله دارد، حساب کاربری‌ای که کمپین را ذخیره می‌کند باید نام نمایشی متفاوتی داشته باشد. **راهی برای خاموش کردن این فیلدها در پاسخ وجود ندارد.**

## ارزیابی قاعده‌ها {#targeting}

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

فیلدهای `targeting` همگی اختیاری‌اند:

| فیلد | معنی |
| --- | --- |
| `url_contains` | آرایه‌ی رشته. اگر **هر کدام** زیررشته‌ی نشانی صفحه باشد، پاس |
| `url_not_contains` | آرایه‌ی رشته. اگر **هر کدام** در نشانی باشد، رد |
| `devices` | `desktop`، `mobile`، `tablet`. خالی یعنی همه |
| `delay_seconds` | int، هنگام ذخیره به بازه‌ی `0` تا `120` محدود می‌شود |
| `scroll_percent` | int، هنگام ذخیره به بازه‌ی `0` تا `100` محدود می‌شود |
| `on_exit_intent` | bool |
| `new_visitors_only` | bool. اگر بازدیدکننده برگشتی باشد، رد |
| `returning_only` | bool. اگر بازدیدکننده برگشتی نباشد، رد |
| `logged_in` | bool اختیاری. نبودنش یعنی هر دو حالت |
| `traits` | نگاشت رشته به رشته. **همه‌ی** کلیدها باید دقیقا برابر باشند |

تطبیق نشانی زیررشته‌ای است و هرگز عبارت باقاعده نیست. عبارت باقاعده‌ای که یک نفر بازاریاب می‌نویسد می‌تواند فاجعه‌بار کند باشد و این کد روی هر صفحه‌ی سایت شما اجرا می‌شود. مجموع `url_contains` و `url_not_contains` هنگام ذخیره حداکثر بیست قاعده است.

`traits` فقط با ویژگی‌هایی مقایسه می‌شود که SDK همان لحظه در حافظه‌ی محلی دارد، یعنی چیزی که خودتان با `identify()` به آن داده‌اید. **با سگمنت‌های انبار داده مقایسه نمی‌شود.** اگر می‌خواهید یک کمپین on-site را به یک سگمنت وصل کنید، این کار از اینجا شدنی نیست.

کلاس دستگاه از عرض viewport می‌آید و نه از user agent: کمتر از ۷۶۸ پیکسل `mobile`، کمتر از ۱۰۲۴ پیکسل `tablet`، بقیه `desktop`.

روی اندروید دو تفاوت هست که اگر ندانید کمپینتان هرگز نمایش داده نمی‌شود. اول اینکه فقط دو کلاس وجود دارد: عرض کوچک‌تر صفحه از ۶۰۰ به بالا `tablet` است و پایین‌تر `mobile`. **پس کمپینی که `devices: ["desktop"]` دارد روی اندروید هیچ‌وقت واجد شرایط نمی‌شود.** دوم اینکه `url_contains` و `url_not_contains` روی گوشی با نام صفحه مقایسه می‌شوند، یعنی همان رشته‌ای که اپ به `screen()` می‌دهد، نه با یک نشانی اینترنتی.

### سقف نمایش {#frequency-cap}

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

ترتیب قاعده‌ها یکسان است و اهمیت دارد:

1. اگر کمپین زنده نیست، خیر.
2. اگر این مرورگر قبلا **تبدیل** شده، دیگر هرگز. این قاعده از همه بالاتر است، حتی از کمپینی که هنوز در جریان است.
3. اگر بسته شده و کمپین `dismissible` است، خیر.
4. اگر `max_impressions > 0` و تعداد نمایش‌ها به آن رسیده، خیر.
5. اگر `cooldown_hours > 0` و آخرین نمایش داخل همان بازه بوده، خیر.

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

سابقه‌ی نمایش‌ها در مرورگر زیر کلید `sg_onsite` در حافظه‌ی محلی نگه داشته می‌شود. مقدار خراب یا نبودنش مثل خالی خوانده می‌شود و خطای سهمیه هنگام نوشتن بی‌صدا رد می‌شود.

### زمان نمایش {#triggers}

- `scroll_percent` بزرگ‌تر از صفر: یک شنونده‌ی passive روی `scroll` وصل می‌شود و در همان درصد آتش می‌کند.
- `on_exit_intent`: شنونده‌ی `mouseout` و آتش کردن وقتی `clientY` صفر یا کمتر شود. عملا فقط روی دسکتاپ کار می‌کند، چون دستگاه لمسی اشاره‌گری ندارد که به سمت نوار تب برود.
- `delay_seconds` **فقط وقتی خودش یک ماشه است که هیچ‌کدام از آن دو تنظیم نشده باشند.** وگرنه با آن‌ها مسابقه می‌داد و پیام را روی تایمری نشان می‌داد که بازاریاب آن را حداقل در نظر گرفته بود.

## کدام نوع پیام واقعا کشیده می‌شود {#kinds}

چهار نوع در پنل قابل ساختن است. سه SDK آن‌ها را یکسان نمی‌کشند و این تفاوت مستند شده تا کسی نصف روز دنبال باگی نگردد که وجود ندارد.

| نوع | SDK وب | SDK اندروید | SDK iOS |
| --- | --- | --- | --- |
| `banner` | کشیده می‌شود | کشیده می‌شود | وجود ندارد |
| `modal` | کشیده می‌شود، با پرده‌ی پشت | کشیده می‌شود | وجود ندارد |
| `slidein` | کشیده می‌شود، در گوشه | **مثل بنر کشیده می‌شود**، بدون انیمیشن | وجود ندارد |
| `survey` | کشیده می‌شود، مقیاس NPS یا فهرست گزینه | **کشیده نمی‌شود** | وجود ندارد |

روی اندروید، نظرسنجی به‌جای اینکه تقریبی کشیده شود، `false` برمی‌گرداند. نتیجه‌اش این است که کمپین بدون سقف و بدون گزارش باقی می‌ماند، پس هر وقت این نوع پشتیبانی شد همان کاربر باز هم آن را می‌بیند و فرصت سوخته نشده است.

**SDK iOS هیچ کد on-site ندارد.** تنها نقطه‌های پایانی‌اش `POST /v1/devices` و `POST /v1/batch` هستند. اگر پیام درون‌اپ روی iOS می‌خواهید، باید خودتان `GET /v1/onsite` را صدا بزنید و ویجت را بکشید.

چند محدودیت رندر در وب که ارزش دانستن دارد:

- ویجت هیچ‌وقت استثنا پرتاب نمی‌کند. یک ابزار تحلیلی نباید چیزی باشد که پرداخت مشتری را می‌شکند.
- هیچ استایلی ارث نمی‌برد. هر خصوصیت مستقیم روی خود عنصر ست می‌شود، پس فقط یک `!important` از سمت صفحه‌ی میزبان می‌تواند بر آن غلبه کند.
- محتوا با `textContent` نوشته می‌شود و هرگز با `innerHTML`. عنوانی که داخلش `<img src=x onerror=...>` باشد به شکل متن رندر می‌شود.
- شناسه‌ی ظرف `segmentic-onsite` است با `z-index: 2147483000`، عمدا کمی پایین‌تر از بیشینه تا لایه‌ی خود مشتری هنوز بتواند بالاتر بنشیند. ظرف `pointer-events: none` دارد تا کلیک‌های سایت را نبلعد، و `direction: rtl` است.
- **همیشه حداکثر یک کمپین در آن واحد نشان داده می‌شود.**
- `href` دکمه فقط وقتی ست می‌شود که با `http://` یا `https://` شروع شود یا با یک `/` جلو بیاید. هر چیز دیگری، از جمله `javascript:`، دکمه را بدون `href` می‌گذارد.
- اگر `image_url` بارگذاری نشود، خود تصویر حذف می‌شود تا آیکون تصویر شکسته نماند.
- دکمه‌ی بستن `aria-label="بستن"` دارد و سمت چپ می‌نشیند، چون فارسی از راست خوانده می‌شود.
- رنگ‌های پیش‌فرض وقتی محتوا آن‌ها را نیاورد: پس‌زمینه `#1f2937`، متن `#ffffff`، رنگ تأکید `#2563eb`.
- ردیف NPS به زور `direction: ltr` می‌گیرد تا صفر سمت چپ و ده سمت راست بنشیند، حتی داخل کارتی که راست‌چین است.

## گزارش رویداد {#report-event}

`POST https://in.segmentic.net/v1/onsite/event`

| فیلد | نوع | لازم |
| --- | --- | --- |
| `campaign_id` | `int64` | بله، صفر رد می‌شود |
| `user_id` | string | یکی از این دو |
| `anonymous_id` | string | یکی از این دو |
| `action` | string | خیر، خالی یعنی `impression` |
| `page_url` | string | خیر |
| `score` | int | فقط نظرسنجی |
| `answers` | نگاشت رشته به رشته | فقط نظرسنجی |

`action` با حروف کوچک خوانده می‌شود و باید یکی از `impression`، رشته‌ی خالی، `click`، `dismiss` یا `convert` باشد. هر چیز دیگری `400` با پیام `unknown action` می‌گیرد.

```bash title="ثبت یک نمایش"
curl -s -X POST https://in.segmentic.net/v1/onsite/event \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"campaign_id":7,"action":"impression","anonymous_id":"a_9f21c4","user_id":"u_9137"}'
```

```json title="پاسخ"
{"status":"ok"}
```

کلید موضوع، وقتی `user_id` باشد `"u:" + user_id` است و وگرنه `"a:" + anonymous_id`. یک ستون و یک کلید، چون سؤال سقف این است که «این مرورگر آن را دیده یا نه» و کسی که وسط جلسه وارد حساب می‌شود همان یک مرورگر است.

اگر `campaign_id` نباشد یا هیچ‌کدام از دو شناسه نیامده باشد، پاسخ `400` است با `campaign_id and a visitor id are required`. بدنه‌ی JSON خراب `400` با پیام `malformed JSON` می‌گیرد.

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

اثر هر کنش روی انبار:

| `action` | اثر |
| --- | --- |
| `impression` | `seen_count` یکی زیاد می‌شود، `last_seen_at` به‌روز می‌شود، شمارنده‌ی `impressions` کمپین هم زیاد می‌شود |
| `dismiss` | `dismissed_at` ست می‌شود (اولی نگه داشته می‌شود)، `dismissals` زیاد می‌شود |
| `click` | **`converted_at`** ست می‌شود و `clicks` زیاد می‌شود |
| `convert` | `converted_at` ست می‌شود و هیچ شمارنده‌ای زیاد نمی‌شود |

دو چیز که در گزارش‌گیری به آن برمی‌خورید:

- **این رویدادها متر و سهمیه‌بندی نمی‌شوند.** برخلاف `/v1/track` و `/v1/batch`، هیچ‌کدام از دو نقطه‌ی پایانی on-site مصرف را نمی‌شمارد و سقف حساب را چک نمی‌کند.
- **این رویدادها روی گذرگاه رویداد منتشر نمی‌شوند.** مستقیم داخل Postgres نوشته می‌شوند. یعنی در هیچ جریان رویدادی، هیچ رله‌ای و هیچ جدول ClickHouse دیده نمی‌شوند، پس نمی‌توانید با ابزارهای گزارش‌گیری معمول رویدادها روی آن‌ها سگمنت بسازید. شمارنده‌های جمعی روی همان فهرست عمومی کمپین‌ها که بالا آمد هستند، و به همین دلیل هر بازدیدکننده‌ای می‌تواند در تب Network ببیندشان. هرچه فراتر از یک عدد جمعی باشد، یعنی نتایج نظرسنجی و تک‌تک پاسخ‌ها، فقط روی مسیرهای کنترل‌پلین در [ساختن و منتشر کردن کمپین](/docs/onsite#managing) است که در پنل به آن‌ها می‌رسید.

## پاسخ نظرسنجی {#survey-response}

`POST https://in.segmentic.net/v1/onsite/response`، با همان ساختار بدنه‌ای که بالا آمد.

ترتیب کار:

1. اگر `campaign_id` صفر باشد یا شناسه‌ی بازدیدکننده نباشد، `400` با `campaign_id and a visitor id are required`.
2. کمپین **از انبار خوانده می‌شود و از بدنه باور نمی‌شود**: اینکه این یک نظرسنجی NPS هست یا نه تعیین می‌کند که `score` اصلا معنایی دارد یا نه، و مرورگر مرجع این موضوع نیست. اگر خواندن شکست بخورد، `400` با `unknown campaign`.
3. اعتبارسنجی، پنج بررسی، در جدول پایین. خطا `400` می‌گیرد و متن خطا خود همان جمله‌ی انگلیسی است.
4. اگر ذخیره شکست بخورد، `503` با `temporarily unavailable, please retry`. **این تنها نقطه‌ی پایانی on-site است که می‌تواند خطای سرور برگرداند.**
5. در موفقیت، یک کنش `convert` هم به‌صورت best-effort ثبت می‌شود. کسی که نظرش را گفته نباید هفته‌ی بعد همان سؤال را ببیند.
6. `200` با `{"status":"ok"}`.

دو تا از این پنج‌تا اصلا خطا نیستند، و دیدنشان کنار آن سه‌تای دیگر ارزش دارد:

| بررسی | چه می‌شود |
|---|---|
| `kind` کمپین `survey` نیست | `400` با `onsite: this campaign is not a survey` |
| نه `user_id` هست و نه `anonymous_id` | `400` با `onsite: a response must name a browser or a person` |
| `content.nps` درست است و `score` بیرون بازه‌ی صفر تا ده است، **یا اصلا نیامده** | `400` با `onsite: an NPS score must be between 0 and 10` |
| `content.nps` نادرست است | `score` با `-1` بازنویسی می‌شود، بدون خطا |
| مقداری در `answers` بلندتر از دو هزار نویسه است | بریده می‌شود، هرگز رد نمی‌شود |

```bash title="پاسخ یک نظرسنجی NPS"
curl -s -X POST https://in.segmentic.net/v1/onsite/response \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"campaign_id":12,"user_id":"u_9137","score":9,"answers":{"reason":"ارسال سریع بود"}}'
```

```json title="پاسخ"
{"status":"ok"}
```

ذخیره روی کلید `(حساب، کمپین، user_id، anonymous_id)` idempotent است و `score` و `answers` را با مقدار تازه جایگزین می‌کند. کسی که پاسخ می‌دهد، صفحه را نو می‌کند و دوباره پاسخ می‌دهد، یک نظر دارد. `responded_at` از ساعت سرور می‌آید و نه از بدنه.

### یک نقص، و دو تا که رفع شد {#survey-defects}

**`page_url` را هیچ SDK منتشرشده‌ای پر نمی‌کند.** فیلد وجود دارد و ذخیره می‌شود، ولی نه SDK وب و نه SDK اندروید آن را نمی‌فرستند. اگر لازمش دارید، خودتان باید بفرستیدش.

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

**پاسخ چندگزینه‌ای و متن آزاد SDK وب بی‌صدا دور ریخته می‌شد.** رندرکننده پاسخ را به شکل `{ score?, choice?, text? }` می‌فرستاد و کلاینت همان را مستقیم داخل بدنه POST باز می‌کرد. ساختار سمت سرور نه فیلد `choice` دارد و نه `text`، پس `encoding/json` هر دو را حذف می‌کرد و جواب `200` می‌داد. یعنی برای نظرسنجی چندگزینه‌ای، ردیف ذخیره‌شده می‌گفت این نفر پاسخ داده و نمی‌گفت چه پاسخی داده. حالا رندرکننده نگاشت `answers` می‌فرستد، همان شکلی که SDK اندروید از اول می‌فرستاد.

**پاسخ متنی تکمیلی، نمره واقعی NPS را با صفر بازنویسی می‌کرد.** روی نظرسنجی NPS که `follow_up` دارد، SDK وب دو بار POST می‌کرد: یک بار با `{"score": 9}` و یک بار با `{"text": "..."}`. POST دوم `score` نداشت، پس مقدار صفر Go به کار می‌رفت، اعتبارسنجی هم می‌پذیرفت چون صفر یک نمره منتقد معتبر است، و ذخیره روی همان کلید نمره واقعی را با آن جایگزین می‌کرد. هر پاسخ تکمیلی یک منتقد به حساب شما اضافه می‌کرد. دو چیز عوض شد: هر POST حالا کل پاسخ را می‌برد نه فقط تکه تازه را، و بدنه‌ای که روی نظرسنجی NPS اصلا `score` ندارد با `400` رد می‌شود به‌جای اینکه صفر خوانده شود.

> [!warning]
> **پاسخ‌هایی که قبل از این رفع ثبت شده‌اند برگشتنی نیستند.** گزینه‌ای که دور ریخته شده هیچ‌وقت نوشته نشد، و نمره‌ای که بازنویسی شده جای نمره واقعی را گرفت. اگر عدد NPS نظرسنجی‌ای که سؤال تکمیلی داشته جایی نقل شده، آن عدد پایین‌تر از واقعیت بوده، به اندازه یک منتقد به ازای هر نفری که جمله‌ای نوشته. نمره‌هایی که بعد از این رفع جمع می‌شوند سالم‌اند.
>
> نصف این رفع داخل بسته SDK است و نصفش سمت سرور. صفحه‌ای که نسخه قدیمی `segmentic.js` را قفل کرده باشد هنوز همان شکل قدیم را می‌فرستد، پس جواب چندگزینه‌ای و متن آزادش هنوز گم می‌شود. ولی نمره‌اش دیگر گم نمی‌شود: سرور حالا پاسخ تکمیلی‌ای را که امتیاز ندارد با `400` رد می‌کند، پس نمره واقعی سر جایش می‌ماند به‌جای اینکه صفر شود. برای برگشتن جواب‌ها، بسته جدید را سرو کنید.

## SDK وب {#web-sdk}

on-site به‌طور پیش‌فرض **روشن** است. یعنی نصب SDK بدون هیچ تنظیم دیگری، کمپین‌های منتشرشده‌ی شما را روی سایتتان می‌کشد.

```ts title="نصب معمول"
import segmentic from "@segmentic/web";

const client = segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
});

segmentic.identify("u_9137", { city: "تهران", plan: "gold" });
```

آنچه پشت پرده می‌افتد: یک بار در `init()` فهرست گرفته می‌شود و یک کمپین کشیده می‌شود، بعد هر شصت ثانیه فهرست دوباره گرفته می‌شود تا با هدر کش سرور جور بماند. اگر کاربر انصراف داده باشد (`optOut()`)، هیچ فهرستی گرفته نمی‌شود و هیچ چیزی کشیده نمی‌شود. خطای شبکه بی‌صدا رد می‌شود.

گزارش‌ها از صف دسته‌ای رد نمی‌شوند: هر دو POST مستقیم و با `keepalive: true` می‌روند. یک نمایش نباید پشت دسته‌ای بماند که منتظر نوزده پیام دیگر است، روی صفحه‌ای که بازدیدکننده دارد ترکش می‌کند.

بعد از پیمایش سمت کلاینت (مثلا در یک اپ تک‌صفحه‌ای) خودتان باید ارزیابی را دوباره راه بیندازید:

```ts title="بعد از تغییر مسیر"
client.refreshOnsite();
```

### ویجت خودتان {#own-widgets}

اگر سیستم طراحی خودتان را دارید، رندر ما را خاموش کنید و فقط منطق واجد شرایط بودن و سقف نمایش را نگه دارید:

```ts title="رندر اختصاصی"
import segmentic, { eligible, deviceOf, readSeen } from "@segmentic/web";

const client = segmentic.init({
  writeKey: "wk_seg_...",
  apiHost: "https://in.segmentic.net",
  onsite: false,
});

// بعد از اینکه فهرست یک بار گرفته شد.
const visitor = {
  url: location.href,
  device: deviceOf(window.innerWidth),
  loggedIn: segmentic.getUserId() !== null,
  returning: true,
  traits: { plan: "gold" },
  now: Date.now(),
};

const [campaign] = eligible(client.onsiteCampaigns(), visitor, readSeen(localStorage));
if (campaign) {
  drawYourOwnWidget(campaign);
}
```

> [!warn]
> `onsiteCampaigns()` و `refreshOnsite()` متدهای نمونه‌ی کلاینت‌اند و روی شیء پیش‌فرض ماژول نیستند. یعنی `segmentic.onsiteCampaigns()` وجود ندارد؛ باید نمونه‌ای را که `init()` برمی‌گرداند نگه دارید. توابع `eligible`، `matches`، `maySee`، `isLive`، `deviceOf`، `readSeen`، `writeSeen`، `recordSeen` و `recordAction` از خود ماژول export شده‌اند.

با `onsite: false` هیچ گزارشی هم خودکار فرستاده نمی‌شود. خودتان باید `POST /v1/onsite/event` را برای نمایش، کلیک و بستن صدا بزنید، وگرنه سقف نمایش سمت سرور هیچ‌وقت پر نمی‌شود و آمار کمپین صفر می‌ماند.

## ساختن و منتشر کردن کمپین {#managing}

این شش مسیر روی کنترل‌پلین‌اند، یعنی همان APIای که پنل با آن حرف می‌زند. روی میزبان مدیریتی هیچ مسیری برای مدیریت کمپین on-site وجود ندارد.

> [!danger]
> **کنترل‌پلین از اینترنت مسیردهی نشده است.** در استقرار مرجع، `api.segmentic.net` فقط API مدیریتی را روی شنونده‌ی خودش سرو می‌کند و شنونده‌ی کنترل‌پلین عمدا منتشر نشده است. همین مسیرها روی `https://api.segmentic.net` به هندلر پیش‌فرض می‌افتند و `404 unknown_endpoint` می‌گیرند. تنها مسیر عمومی به جدول زیر، پروکسی سمت سرور خود پنل است روی `https://app.segmentic.net/api/proxy/v1/...` که با کوکی نشست کاربر واردشده احراز هویت می‌کند و بدون آن `401` می‌دهد. کلید `sk_seg_` به آن نمی‌رسد. پس ساختن و منتشر کردن کمپین on-site کاری است که یک آدم در پنل انجام می‌دهد.

| متد و مسیر | دسترسی لازم |
| --- | --- |
| `GET /v1/onsite/campaigns` | `campaign.read` |
| `GET /v1/onsite/campaigns/{id}` | `campaign.read` |
| `PUT /v1/onsite/campaigns` | `campaign.write` |
| `POST /v1/onsite/campaigns/{id}/status` | **`campaign.send`** |
| `GET /v1/onsite/campaigns/{id}/results` | `campaign.read` |
| `GET /v1/onsite/campaigns/{id}/responses` | **`profile.read`** |

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

**ذخیره کردن هرگز منتشر نمی‌کند.** اگر در بدنه‌ی `PUT` مقدار `status` را `live` بگذارید، به `draft` بازنویسی می‌شود. `status` خالی هم `draft` می‌شود.

```http title="ساختن یک بنر"
PUT /api/proxy/v1/onsite/campaigns
Content-Type: application/json

{
  "campaign": {
    "name": "ارسال رایگان مرداد",
    "kind": "banner",
    "content": {
      "headline": "ارسال رایگان تا پایان مرداد",
      "button_text": "خرید",
      "button_url": "/products",
      "position": "top"
    },
    "targeting": { "url_not_contains": ["/checkout"], "delay_seconds": 3 },
    "max_impressions": 3,
    "cooldown_hours": 24,
    "dismissible": true
  }
}
```

```http title="زنده کردنش"
POST /api/proxy/v1/onsite/campaigns/7/status
Content-Type: application/json

{"status":"live"}
```

```json title="پاسخ"
{"id":7,"status":"live"}
```

`status` فقط `draft`، `live`، `paused` یا `ended` را می‌پذیرد و هر چیز دیگری `400` می‌گیرد.

خطاهای اعتبارسنجی `PUT`، همگی با کد `400` و متن فارسی:

| شرط | چه وقت |
| --- | --- |
| نام لازم است | `name` بعد از trim خالی باشد |
| نوع ناشناس | `kind` یکی از آن چهارتا نباشد |
| محتوا لازم است | کمپین غیرنظرسنجی که هم `headline` و هم `body` خالی دارد |
| سؤال لازم است | نظرسنجی بدون `question` |
| پیوند نامعتبر | `button_url` یا `image_url` که با `http://`، `https://` یا `/` شروع نشود |
| هر دو مخاطب با هم | `new_visitors_only` و `returning_only` هر دو درست |
| قاعده‌های زیادی | بیش از بیست قاعده‌ی نشانی |
| گزینه‌های زیادی | نظرسنجی با بیش از هشت گزینه |

مقادیری که هنگام ذخیره خودکار اصلاح می‌شوند:

- `max_impressions` کوچک‌تر یا مساوی صفر به `3` تبدیل می‌شود.
- `cooldown_hours` منفی به `24` تبدیل می‌شود. توجه کنید که صفر صریح دست‌نخورده می‌ماند و یعنی بدون فاصله.
- `kind` برابر `modal` مقدار `dismissible` را به زور درست می‌کند. مودالی که بسته نمی‌شود پیام نیست، گروگان‌گیری است.
- نظرسنجی NPS فهرست `choices` را از دست می‌دهد.
- دکمه‌ای که متن دارد و نشانی ندارد، یا برعکس، نیمه‌ی باقی‌مانده‌اش حذف می‌شود.
- `javascript:` و `data:` رد می‌شوند. آن پیوند داخل صفحه‌ی خود مشتری رندر می‌شود و در همان مبدأ و با همان کوکی‌ها اجرا می‌شد.

`GET /v1/onsite/campaigns` حداکثر صد کمپین برمی‌گرداند و پاسخش `{"campaigns": [...], "kinds": [...]}` است. هر کمپین علاوه بر فیلدهای معمول، `kind_label`، `status_label` و `ctr` دارد. `ctr` برابر `clicks / impressions * 100` است و با صفر نمایش، صفر می‌شود و نه خطای تقسیم.

### نتیجه‌ی NPS {#nps}

```http title="خواندن نتیجه"
GET /api/proxy/v1/onsite/campaigns/12/results
```

```json title="پاسخ"
{
  "nps": {
    "responses": 128,
    "promoters": 61,
    "passives": 40,
    "detractors": 27,
    "score": 26.5625,
    "reliable": true
  },
  "min_reliable": 50
}
```

حساب NPS: درصد ترویج‌کننده منهای درصد منتقد. **بی‌تفاوت‌ها در مخرج هستند و جای دیگری نیستند**، و همین جایی است که هر پیاده‌سازی دوباره‌ای اشتباه می‌کند: حذفشان نمره را باد می‌کند.

سطل‌ها ثابت‌اند و قابل تنظیم نیستند: نه و ده ترویج‌کننده، هفت و هشت بی‌تفاوت، صفر تا شش منتقد. NPS فقط به این دلیل ارزش نقل کردن دارد که همه‌جا یک معنی می‌دهد.

`reliable` زیر پنجاه پاسخ نادرست است. NPS با یازده پاسخ، با یک پاسخ بیشتر بیست واحد جابه‌جا می‌شود، و عددی که در جلسه‌ی هیئت‌مدیره نقل می‌شود نباید این‌طور باشد.

پاسخ‌های خام:

```http title="خواندن پاسخ‌های خام"
GET /api/proxy/v1/onsite/campaigns/12/responses?limit=200
```

`limit` بین `1` و `500` پذیرفته می‌شود و هر مقدار بیرون این بازه، از جمله مقدار نامعتبر یا نبودنش، به `100` تبدیل می‌شود. پاسخ‌ها از تازه به قدیم مرتب‌اند. **صفحه‌بندی وجود ندارد**: نه cursor و نه offset.

## صندوق پیام {#inbox}

صندوق پیام جای دیگری است که پیام‌های کمپین در آن می‌مانند تا اپ دفعه‌ی بعد باز شود. دو نقطه‌ی پایانی روی collector دارد و هر دو POST‌اند:

```text
POST /v1/inbox
POST /v1/inbox/ack
```

POST و نه GET، به دو دلیل. شناسه‌ی کاربر و اثباتش باید در بدنه باشد و نه در رشته‌ی پرس‌وجویی که هر پروکسی و تاریخچه‌ی مرورگر و لاگ دسترسی نسخه‌ای از آن نگه می‌دارد. و گرفتن صندوق اثر جانبی دارد: ردیف‌ها با علامت «تحویل شد» برمی‌گردند، و این کاری نیست که GET اجازه داشته باشد بکند.

### احراز هویت دومرحله‌ای {#inbox-auth}

اینجا تنها جایی است که کلید نوشتن به تنهایی کافی نیست. صندوق نخستین نقطه‌ی پایانی **خواندنی** این پلتفرم است، ردیف‌هایش متن پیام و کد تخفیف شخصی‌سازی‌شده دارند، و «صندوق کاربر ۹۱۳۷۲ را بده» پشت کلیدی که هر کسی می‌تواند از سورس صفحه بخواند، نقطه‌ی پایانی‌ای نیست که بتواند وجود داشته باشد.

پس دو بررسی جدا انجام می‌شود:

1. کلید نوشتن حساب را مشخص می‌کند. عمومی است و چیزی درباره‌ی اینکه چه کسی می‌پرسد نمی‌گوید.
2. `user_hash` ثابت می‌کند که سرور خود شما این آدم را احراز هویت کرده است.

فرمول، که سرور شما هنگام ورود کاربر آن را حساب می‌کند:

```text title="فرمول user_hash"
user_hash = hex(hmac_sha256(identity_secret, user_id))
```

مقایسه در زمان ثابت انجام می‌شود و مقدار ورودی trim و به حروف کوچک تبدیل می‌شود. اگر حساب `identity_secret` نداشته باشد، یا `user_id` خالی باشد، یا اثبات خالی باشد، نتیجه نادرست است.

> [!danger]
> **راهی برای ساختن `identity_secret` از پنل یا از API وجود ندارد.** تنها نویسنده‌ی این مقدار در کل درخت کد، ابزار خط فرمان `adminctl` است که سی‌ودو بایت تصادفی می‌سازد، آن را base64url می‌کند و فرمول بالا را چاپ می‌کند. یعنی مشتری نمی‌تواند خودش صندوق پیام را راه بیندازد و باید از ما بخواهد. چرخاندن این کلید هم هر هش صادرشده را باطل می‌کند و تا وقتی مشتری دوباره deploy نکند، کل اپش از صندوقش بیرون می‌افتد.

پاسخ‌های خطا:

| حالت | کد | بدنه |
| --- | --- | --- |
| `user_id` نیامده | `400` | `{"status":"error","message":"user_id is required"}` |
| هش غلط، هش نیامده، یا حسابی که اصلا `identity_secret` ندارد | `403` | `{"status":"error","message":"user identity is not verified"}` |

کد `403` و نه `404`، و پاسخ در هر دو حالت یکسان، تا نشود از این نقطه‌ی پایانی به‌عنوان اوراکلی برای فهمیدن اینکه چه شناسه‌های کاربری وجود دارند استفاده کرد.

```bash title="گرفتن صندوق"
curl -s -X POST https://in.segmentic.net/v1/inbox \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id":"u_9137","user_hash":"3f2a1c...","limit":25}'
```

```json title="پاسخ"
{
  "status": "ok",
  "messages": [
    {
      "message_id": "c104.u_9137",
      "title": "سفارش شما ارسال شد",
      "body": "کد رهگیری در اپلیکیشن قابل مشاهده است.",
      "image": "https://cdn.example.ir/box.png",
      "deeplink": "myapp://orders/104",
      "surface": "inbox",
      "token": "1.42.k1.oi.mfx2b1.QkNERUZHSElK",
      "created_at": "2026-08-06T08:11:00Z",
      "expires_at": "2026-09-05T08:11:00Z",
      "seen": false
    }
  ]
}
```

`messages` همیشه آرایه است و هرگز `null` نیست. اگر انبار از کار بیفتد، پاسخ `503` است با `{"status":"error","messages":null,"message":"temporarily unavailable, please retry"}`.

`limit` کوچک‌تر یا مساوی صفر یا بزرگ‌تر از `25` به `25` تبدیل می‌شود. فقط ردیف‌هایی برمی‌گردند که رد نشده‌اند و منقضی نشده‌اند، و از تازه به قدیم مرتب‌اند. همان دستور، `delivered_at` را هم ست می‌کند، چون این همان لحظه‌ای است که می‌شود اثباتش کرد: بایت‌ها از سرور خارج شدند. اینکه آن آدم بعدش نگاه کرده یا نه `seen_at` است و ادعای دیگری و ضعیف‌تری است.

`token` همان چیزی است که ثابت می‌کند بازکردنی که اپ گزارش می‌کند به پیامی مربوط است که ما واقعا فرستاده‌ایم. بدون آن، هر بازکردن درون‌اپ یک ادعای بی‌امضاست که دفتر ثبتش می‌کند و نمی‌شمارد.

تأیید دیده شدن یا رد کردن:

```bash title="تأیید"
curl -s -X POST https://in.segmentic.net/v1/inbox/ack \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id":"u_9137","user_hash":"3f2a1c...","seen":["c104.u_9137"],"dismissed":["c99.u_9137"]}'
```

```json title="پاسخ"
{"status":"ok"}
```

`seen` نخستین لحظه‌ی دیده شدن را نگه می‌دارد و بازنویسی نمی‌شود، تا پیامی که در هر بار باز شدن اپ دوباره رندر می‌شود، برای همیشه زمان بازکردنش را «همین الان» گزارش نکند. `dismissed` ردیف را از فهرست‌های بعدی حذف می‌کند. آرایه‌ی خالی خطا نیست و هیچ کاری نمی‌کند. شکست هر کدام از این دو `503` می‌گیرد.

**`seen` سیگنال تعامل نیست.** برای اینکه یک بازکردن درون‌اپ شمرده شود، اپ باید یک رویداد `message_opened` از مسیر عادی رویداد بفرستد، با `context.campaign.message_id` و `context.campaign.token` که خود صندوق به آن داده است. راه دوم و «مورد اعتماد» برای همان سیگنال، چیزی می‌شد که فقط به حرف تماس‌گیرنده‌ای اعتماد می‌کرد که چیزی جز یک کلید نوشتن عمومی ندارد.

### آنچه اپ شما باید بسازد {#inbox-build}

**هیچ SDK منتشرشده‌ای کلاینت صندوق ندارد.** نه وب، نه اندروید، نه iOS. آنچه شما می‌سازید:

1. حساب کردن `user_hash` روی سرور خودتان هنگام ورود کاربر. `identity_secret` نباید هرگز به مرورگر یا به باینری اپ برسد.
2. صدا زدن `POST /v1/inbox` و نگه داشتن نتیجه.
3. کل رابط کاربری: فهرست، حالت خوانده و نخوانده، ژست رد کردن، و باز کردن `deeplink`.
4. فرستادن `message_opened` با `message_id` و `token` تا بازکردن‌ها قابل شمردن باشند.
5. صفحه‌بندی. **وجود ندارد.** بیست‌وپنج ردیف در هر تماس، بدون cursor و بدون offset. اگر کاربری صد پیام دارد، فقط بیست‌وپنج تای تازه‌تر را می‌بینید تا وقتی بقیه را رد کنید یا منقضی شوند.

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

## چه چیزی امروز نیست {#not-built}

فهرست صادقانه‌ی کارهایی که یک مشتری انتظارشان را دارد و امروز وجود ندارند:

- **on-site روی iOS.** هیچ کدی نیست.
- **نظرسنجی روی اندروید.** رندرکننده `false` برمی‌گرداند.
- **انیمیشن slide-in روی اندروید.** مثل بنر کشیده می‌شود.
- **وصل کردن هدف‌گیری on-site به سگمنت.** `traits` فقط با ویژگی‌های محلی SDK مقایسه می‌شود.
- **صفحه‌بندی صندوق پیام** و **کلاینت صندوق در هر SDK**.
- **ساختن `identity_secret` بدون ما.** فقط از خط فرمان.
- **دیدن رویدادهای on-site در گزارش‌های رویدادمحور.** روی گذرگاه منتشر نمی‌شوند.
- **خاموش کردن شمارنده‌ها و `created_by` در پاسخ عمومی `GET /v1/onsite`.**
- **پر کردن `page_url` از سمت SDK.** ستونش هست و هیچ‌کس رویش نمی‌نویسد. بخش [یک نقص، و دو تا که رفع شد](/docs/onsite#survey-defects).

اگر یکی از این‌ها مسیر شما را می‌بندد، بگویید. اینجا نوشته شده تا بعد از یک بعدازظهر کشف نشود.

---

# گزارش و خروجی گرفتن

> ساخت قیف و گزارش ماندگاری، و ورود و خروج امن داده از پنل.
> https://segmentic.net/docs/reports

روی میزبان مدیریت `https://api.segmentic.net` دو گزارش وجود دارد و نه بیشتر: قیف و ماندگاری. باقی چیزهایی که در پنل می‌بینید، مسیر، ریزش، RFM، درگیری، داشبوردساز، هیچ‌کدام آدرس عمومی ندارند. فهرست کامل در [آنچه فقط در پنل هست](/docs/reports#panel-only) و [آنچه ممکن نیست](/docs/reports#not-possible) آمده است.

هر دو گزارش با کلید API (`sk_seg_...`) کار می‌کنند، نه با کلید نوشتن. کلید نوشتن روی `in.segmentic.net` است و به این آدرس‌ها دسترسی ندارد.

روی نصب محلی، تا وقتی `PUBLIC_API_ADDR` را ست نکنید این میزبان اصلاً بالا نمی‌آید. کالکتور روی `http://localhost:8080` جدا کار می‌کند.

> نمودار: How exposure, conversion and holdout observations become funnel, retention and lift reports

## قیف {#funnel}

`POST /v1/reports/funnel`. دسترسی لازم `analytics.read`. هزینه ۲۵ واحد از سقف درخواست. مهلت اجرا ۴۵ ثانیه. حجم بدنه حداکثر ۱ مگابایت.

عددی که برمی‌گردد **تجمعی** است. `users` در هر مرحله یعنی هر کسی که به این مرحله یا جلوتر رسیده، نه هر کسی که دقیقاً همین‌جا ایستاده. ClickHouse با `windowFunnel` دورترین مرحله‌ای را که هر کاربر رسیده گزارش می‌کند و ما هیستوگرام را از انتها جمع می‌زنیم. اگر مستقیم بخوانیدش، قیفی می‌سازید که مرحله‌های بعدی‌اش کاربر بیشتری از مرحله‌های اول دارند.

```bash
curl -X POST https://api.segmentic.net/v1/reports/funnel \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "steps": [
          {"name": "product_viewed", "label": "دیدن محصول"},
          {"name": "add_to_cart"},
          {"name": "purchase"}
        ],
        "range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
        "window": "7d"
      }'
```

| فیلد | نوع | اجباری | پیش‌فرض | قاعده |
|---|---|---|---|---|
| `steps` | آرایه | بله | ندارد | بین ۲ تا ۱۲ عضو |
| `steps[].name` | رشته | بله | ندارد | نام رویداد، حداکثر ۲۵۶ بایت |
| `steps[].label` | رشته | خیر | همان `name` | فقط برچسب نمودار |
| `steps[].filters` | آرایه | خیر | ندارد | حداکثر ۱۰ عضو |
| `range.from` | RFC3339 | بله | ندارد | شامل خودش |
| `range.to` | RFC3339 | بله | ندارد | شامل خودش نیست |
| `window` | رشته | بله | ندارد | مثل `"7d"` یا `"2h"` یا `"30m"` |
| `strict` | بولی | خیر | `false` | یعنی هیچ رویداد دیگری بین دو مرحله نباشد |
| `split_by` | رشته | خیر | خالی | فهرست مجاز پایین‌تر |

`from` شامل خودش است و `to` نیست، تا دو بازه‌ی پشت‌سرهم رویدادهای مرز را دوبار نشمارند. طول بازه بیشتر از ۷۳۰ روز نمی‌شود.

`window` را خودمان پارس می‌کنیم چون Go واحد روز ندارد. `"7d"` و `"1d"` و `"0.5d"` و `"2h"` و `"30m"` قبول‌اند. `""` و `null` صفر می‌شوند و صفر رد می‌شود. `"tomorrow"` خطای خواندن JSON است. پنجره‌ای بلندتر از خود بازه هم رد می‌شود: کسی نمی‌تواند در گزارش سی‌روزه نود روز طول بکشد تا تبدیل شود.

> [!warn]
> هیچ‌جای این پلتفرم نام رویداد را اعتبارسنجی نمی‌کند. یک غلط تایپی در `steps[].name` قیفی کاملاً درست با اعداد صفر برمی‌گرداند، که از یک مخاطب واقعی صفرنفره قابل تشخیص نیست. قبل از نوشتن قیف، `GET /v1/schema/events` را صدا بزنید و ببینید نام واقعاً وجود دارد.

کاربران ناشناس (`user_id` خالی) و ترافیک ربات از هر قیف، ماندگاری و مسیری بیرون‌اند. ربات‌ها در انبار داده پاک نمی‌شوند، فقط با `is_bot` علامت می‌خورند و گزارش‌ها کنارشان می‌گذارند: افت ترافیکی که کسی نتواند توضیحش بدهد، اعتماد به کل اعداد را از بین می‌برد.

### فهرست قیف‌های ذخیره‌شده {#funnel-library}

پنل قیف‌های نام‌دار نگه می‌دارد. یک بار تعریف را ذخیره کنید و در فهرستی می‌نشیند که هر عضوش نمودار خودش را می‌کشد، و همین نکته‌اش است: بیست قیف کنار هم، همان‌جایی است که آدم می‌فهمد یکی‌شان سه‌شنبه خراب شده.

**هیچ‌کدام از این‌ها آدرس عمومی ندارد.** فهرست روی کنترل‌پلین پنل است که عمداً از بیرون route نمی‌شود، پس `GET /v1/funnels` با کلید مدیریتی در دسترس نیست. این بخش را نوشته‌ایم تا کسی یک ساعت وقت نگذارد و بعد ۴۰۴ بگیرد. آنچه API می‌دهد همان `POST /v1/reports/funnel` این صفحه است، که قیف را از تعریفی که خودتان نگه داشته‌اید حساب می‌کند.

وقتی هنوز قیفی نساخته‌اید، فهرست فقط یک حالت خالی و دکمهٔ «شروع از قیف‌های آماده» نشان می‌دهد. قیف‌های آماده تا وقتی این دکمه را نزنید باز نمی‌شوند. برای هر نوع کسب‌وکار یک الگو هست و مرحله‌هایش همان‌هایی است که [فرهنگ‌نامهٔ رویدادها](/docs/event-dictionary) برای همان صنف منتشر می‌کند. برداشتن هرکدام یک قیف معمولی می‌سازد و هیچ چیزی در نتیجه یادش نمی‌ماند که از یک الگو آمده. هر الگو اول با کاتالوگ رویدادهای خود حساب سنجیده می‌شود و اگر رویدادی را نام ببرد که حساب هرگز نفرستاده، همان‌جا می‌گوید؛ چون مرحله‌ای که کسی نمی‌فرستد قیفی کاملاً درست با اعداد صفر برمی‌گرداند و این از یک مخاطب واقعی صفرنفره قابل تشخیص نیست.

دو چیز درباره‌ی این فهرست ارزش دانستن دارد، حتی اگر فقط با API کار می‌کنید.

نمودار هر کارت موقع باز شدن صفحه حساب نمی‌شود. یک کار پس‌زمینه هر قیف ذخیره‌شده را دوره‌ای دوباره حساب می‌کند و جواب را روی همان سطر می‌نویسد، چون کشیدن زنده‌شان یعنی به ازای هر کارت یک `windowFunnel` روی کل جدول رویدادها، در هر بازدید. پس هر کارت می‌تواند چند ساعت عقب باشد، و هر کارت تاریخ عددش را می‌نویسد. برای عدد همین لحظه، قیف را باز کنید و اجرایش کنید.

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

### فیلترها {#funnel-filters}

هر فیلتر سه فیلد دارد: `{"prop": "...", "op": "...", "value": "..."}`. `value` **همیشه رشته است**، حتی برای عملگرهای عددی.

| گروه | عملگرها | روی چه ستونی |
|---|---|---|
| متنی | `eq`، `ne`، `contains`، `prefix` | `props_str` |
| عددی | `gt`، `gte`، `lt`، `lte`، `num_eq`، `num_ne` | `props_num` |

عملگر ناشناخته خطای ۴۰۰ می‌دهد. طول `prop` حداکثر ۱۲۸ بایت و طول `value` حداکثر ۵۱۲ بایت است.

یک نکته که وقت آدم را می‌گیرد: اگر `value` یک عملگر عددی به عدد اعشاری تبدیل نشود، به‌جای خطا به‌صورت عدد `0` رندر می‌شود، یعنی به هیچ سطری نمی‌خورد. دلیلش این است که نیمه‌تایپ‌شدن یک عدد در رابط کاربری نباید کل نمودار را با stack trace خالی کند. نتیجه‌اش برای شما این است که یک غلط تایپی در فیلتر عددی، بی‌سروصدا قیف خالی می‌سازد.

```json
{
  "steps": [
    {"name": "product_viewed",
     "filters": [{"prop": "category", "op": "eq", "value": "mobile"}]},
    {"name": "purchase",
     "filters": [{"prop": "amount", "op": "gte", "value": "500000"}]}
  ],
  "range": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
  "window": "2d"
}
```

### شکستن به بخش {#funnel-breakdown}

`split_by` یا یکی از این کلیدهای مجاز است، یا شکل `prop:` به‌علاوه‌ی نام یک ویژگی.

| `split_by` | ستون |
|---|---|
| `platform` | `os_name` |
| `os` | `os_name` |
| `device` | `device_type` |
| `app_version` | `app_version` |
| `country` | `country` |
| `city` | `city` |
| `region` | `region` |
| `province` | `region` |
| `utm_source` | `utm_source` |
| `utm_campaign` | `utm_campaign` |
| `browser` | `browser_name` |

`prop:category` روی `props_str` گروه می‌کند، عمداً نه روی `props_num`: یک ویژگی عددی به‌عنوان بعد شکستن، نموداری با چهار هزار میله می‌سازد. کلید بعد از `prop:` هیچ اعتبارسنجی ندارد؛ کلید ناشناخته همه‌چیز را زیر رشته‌ی خالی جمع می‌کند، که جواب درست و خواناتری از خطاست.

مقدار ناشناخته‌ای که با `prop:` شروع نشود، ۴۰۰ می‌گیرد با متن انگلیسی خام `analytics: unknown breakdown "..."`.

### پاسخ قیف {#funnel-response}

```json
{
  "steps": [
    {"index": 0, "name": "product_viewed", "label": "دیدن محصول",
     "users": 1000, "from_start": 1.0, "from_previous": 1.0, "dropped_here": 0},
    {"index": 1, "name": "add_to_cart", "label": "add_to_cart",
     "users": 600, "from_start": 0.6, "from_previous": 0.6, "dropped_here": 400},
    {"index": 2, "name": "purchase", "label": "purchase",
     "users": 300, "from_start": 0.3, "from_previous": 0.5, "dropped_here": 300}
  ],
  "entered": 1000,
  "completed": 300,
  "conversion": 0.3,
  "description": "کاربرانی که «دیدن محصول» سپس ... را به ترتیب انجام دادند، حداکثر در ۷ روز."
}
```

- `from_start` و `from_previous` و `conversion` همه کسری بین صفر و یک‌اند، نه درصد.
- `from_previous` مرحله‌ی صفر همیشه `1` است.
- `dropped_here` مرحله‌ی صفر همیشه صفر است.
- تقسیم بر صفر مهار شده: قیف خالی صفر می‌دهد، نه `NaN`.
- `description` از همان درخواستی ساخته می‌شود که کوئری از آن ساخته شد، پس نمی‌تواند از اعداد زیرش جدا بیفتد. تاریخ‌ها روی این میزبان همیشه جلالی‌اند، چون این میزبان `Accept-Language` را نمی‌خواند و زبان پیش‌فرضش فارسی است.

با `split_by`، کلید `buckets` هم اضافه می‌شود، مرتب‌شده بر اساس `entered` نزولی. `steps` و `entered` و `completed` سطح بالا همچنان کل قیف روی همه‌ی بخش‌ها هستند، نه بزرگ‌ترین بخش.

```json
{
  "steps": [],
  "buckets": [
    {"value": "ios", "steps": [], "entered": 1000, "completed": 100, "conversion": 0.1},
    {"value": "android", "steps": [], "entered": 200, "completed": 100, "conversion": 0.5}
  ],
  "entered": 1200,
  "completed": 200,
  "conversion": 0.16666666666666666,
  "description": "..."
}
```

اگر تنها کلید بخش‌بندی رشته‌ی خالی باشد، `buckets` اصلاً نمی‌آید.

## ماندگاری {#retention}

`POST /v1/reports/retention`. همان دسترسی، همان هزینه ۲۵ واحد، همان مهلت ۴۵ ثانیه.

```bash
curl -X POST https://api.segmentic.net/v1/reports/retention \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "start":  {"name": "signup"},
        "return": {"name": "purchase"},
        "range":  {"from": "2026-05-01T00:00:00Z", "to": "2026-05-10T00:00:00Z"},
        "granularity": "day",
        "periods": 3
      }'
```

| فیلد | نوع | اجباری | پیش‌فرض | قاعده |
|---|---|---|---|---|
| `start` | همان شکل مرحله | خیر | `{}` | نام خالی یعنی «هر فعالیتی» |
| `return` | همان شکل مرحله | خیر | `{}` | نام خالی یعنی «هر فعالیتی» |
| `range` | `{from, to}` | بله | ندارد | مثل قیف، حداکثر ۷۳۰ روز |
| `granularity` | رشته | خیر | `day` | `day` یا `week` یا `month` |
| `periods` | عدد | خیر | ۳۰ | بین ۱ تا ۶۰ |

`start` و `return` دو فیلد جدا هستند چون «برگشت» به‌ندرت یعنی «همان کار را دوباره کرد». اگر هر دو خالی باشند گزارش روی هر فعالیتی حساب می‌شود.

`periods` صفر یا منفی به ۳۰ تبدیل می‌شود، ولی بیشتر از ۶۰ خطای ۴۰۰ می‌گیرد با متن انگلیسی `analytics: at most 60 periods`. جدولی که بیشتر از ۱۲۰ سطر کوهورت بسازد هم رد می‌شود: یک سال کامل با دانه‌بندی روز رد می‌شود، همان بازه با دانه‌بندی ماه قبول است.

> [!warn]
> این نقطه‌ی پایانی فیلد `event` ندارد و کلیدهای ناشناخته را هم رد نمی‌کند، فقط نادیده می‌گیرد. اگر `{"event": "purchase"}` بفرستید، `start` و `return` خالی می‌مانند و جدولی از «هر فعالیتی» می‌گیرید که هیچ‌جا نمی‌گوید سوال شما را نفهمیده است. تنها سرنخ، جمله‌ی `description` است که در آن «هر فعالیتی» نوشته شده. همین اشکال، ابزار آماده‌ی MCP ما را هم گرفتار می‌کند؛ [صفحه‌ی MCP](/docs/mcp) را ببینید.

### کوهورت جلالی {#cohorts-jalali}

هیچ‌کدام از توابع تقویمی ClickHouse برای این بازار درست نیستند، پس مرز هر سطل در Go و در وقت `Asia/Tehran` حساب می‌شود و از ClickHouse فقط پرسیده می‌شود که هر زمان در کدام سطل می‌افتد.

| دانه‌بندی | شروع سطل | گام بعدی |
|---|---|---|
| `day` | نیمه‌شب تهران | یک روز |
| `week` | شنبه | هفت روز |
| `month` | روز اول ماه جلالی | یک روز بعد از پایان همان ماه جلالی |

`toStartOfMonth` گرگوری است، پس گزارش «ماهانه» ده روز آن‌طرف‌تر از جایی بریده می‌شد که هر کاربر ایرانی فکر می‌کند ماه شروع می‌شود. `toStartOfWeek` فقط دوشنبه یا یکشنبه می‌دهد و هفته‌ی ایرانی شنبه شروع می‌شود، پس هر هفته میان دو سطر تقسیم می‌شد.

حساب ماه هم «به‌علاوه‌ی ۳۱ روز» نیست: ماه جلالی ۲۹ یا ۳۰ یا ۳۱ روز است و جمع‌زدن ۳۱ روز یک ماه سی‌روزه را کامل رد می‌کند. مرزها در Go ساخته و به‌صورت `Array(Date)` به ClickHouse داده می‌شوند.

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

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

### خانه‌هایی که هنوز معلوم نیستند {#observable}

هر خانه یک فیلد `observable` دارد. `false` یعنی «گزارش هنوز آن‌قدر عمر نکرده که بداند»، نه صفر. کوهورتی که دیروز شروع شده عدد روز سی‌ام ندارد، و نمایش آن خانه به‌صورت صفر درصد، همان کاری است که یک محصول سالم را در حال مرگ نشان می‌دهد.

مرز، آخرین سطلی است که کاملاً تمام شده، سنجیده با ساعتی که یک‌بار در هر درخواست خوانده می‌شود تا همه‌ی خانه‌های یک پاسخ با یک لحظه سنجیده شوند. دوره‌ی در جریان در جدول می‌آید ولی از منحنی میانگین بیرون است.

### پاسخ ماندگاری {#retention-response}

```json
{
  "granularity": "day",
  "period_label": "روز",
  "cohorts": [
    {
      "cohort": "2026-05-01",
      "label": "۱۱ اردیبهشت ۱۴۰۵",
      "size": 100,
      "cells": [
        {"period": 0, "users": 100, "rate": 1.0, "observable": true},
        {"period": 1, "users": 40, "rate": 0.4, "observable": true},
        {"period": 2, "users": 25, "rate": 0.25, "observable": true},
        {"period": 3, "users": 0, "rate": 0.0, "observable": false}
      ]
    }
  ],
  "average": [
    {"period": 0, "users": 10004, "rate": 1.0, "observable": true},
    {"period": 1, "users": 1004, "rate": 0.10036, "observable": true}
  ],
  "description": "از کاربرانی که برای اولین بار «signup» انجام دادند ..."
}
```

- `cohort` کلید ماشینی است و همیشه `YYYY-MM-DD` گرگوری از لحظه‌ی تهران. `label` سرستون خواندنی است و روی این میزبان جلالی با ارقام فارسی می‌آید.
- `cells` همیشه دقیقاً یکی بیشتر از `periods` عضو دارد، از دوره‌ی صفر تا آخر.
- `rate` کسری بین صفر و یک است.
- `average` منحنی **وزنی** است: مجموع برگشتی‌ها تقسیم بر مجموع شروع‌کننده‌ها، فقط روی خانه‌های `observable`. میانگین ساده‌ی درصدهای هر کوهورت نیست، چون کوهورت چهارنفره‌ای که همه برگشتند، منحنی را به‌اندازه‌ی کوهورت چهل‌هزارنفره بالا می‌کشید.
- کوهورت‌هایی که هیچ سطری ندارند اصلاً در `cohorts` نمی‌آیند. نتیجه‌ی خالی یعنی `"cohorts": []`.

## هزینه، مهلت و شکل خطا {#report-errors}

این دو نقطه‌ی پایانی همان تابع‌های داخلی پنل‌اند که روی میزبان مدیریت هم ثبت شده‌اند. نتیجه‌اش یک ناهمخوانی واقعی است که باید بدانید:

| نوع خرابی | پاکت پاسخ | کد وضعیت |
|---|---|---|
| کلید نبود، نوع کلید غلط بود، کلید منقضی بود | `{"error":{"code":"unauthenticated"}}` | ۴۰۱ |
| دسترسی نبود | `{"error":{"code":"forbidden","need":"analytics.read"}}` | ۴۰۳ |
| سقف درخواست تمام شد | `{"error":{"code":"budget_exhausted"}}` با هدر `Retry-After: 60` | ۴۲۹ |
| حساب قفل نرم خورده | `{"error":{"code":"account_locked","details":{"reason":"usage_300"}}}` | ۴۰۳ |
| JSON خراب بود | `{"error":"یک جمله‌ی فارسی"}` | ۴۰۰ |
| گزارش نامعتبر بود | `{"error":"یک جمله‌ی فارسی","code":"invalid_report"}` | ۴۰۰ |
| انبار داده جواب نداد | `{"error":"یک جمله‌ی فارسی"}` | ۵۰۳ |

> [!danger]
> کلاینتی که فقط `error.code` را می‌خواند، روی هر ۴۰۰ و هر ۵۰۳ این دو نقطه‌ی پایانی می‌شکند، چون آن پاسخ‌ها `error` را رشته می‌فرستند نه شیء. هر دو شکل را هندل کنید.

نه خطای اعتبارسنجی متفاوت (مرحله‌ی کم، مرحله‌ی زیاد، نام خالی، بازه‌ی بد، بازه‌ی پهن، پنجره‌ی بد، دانه‌بندی بد، عملگر بد، عمق زیاد) همگی یک کد دارند: `invalid_report`. متن جمله فرق می‌کند، کد فرق نمی‌کند. خرابی انبار داده اصلاً کد ندارد.

سقف درخواست ۶۰۰ واحد در دقیقه است و روی **هر کلید** حساب می‌شود، نه روی حساب. هر گزارش ۲۵ واحد است، پس یک کلید در هر دقیقه ۲۴ گزارش می‌تواند بگیرد. با `PUBLIC_API_BUDGET_PER_MINUTE` قابل تغییر است. اگر سنجه‌ی سقف خوانده نشود، درخواست رد می‌شود نه رها: `budget_unavailable` با ۵۰۳.

قفل نرم وقتی می‌افتد که مصرف به سیصد درصد سهمیه برسد یا فاکتوری ۷۵ روز عقب بیفتد. فقط چهار مسیر را می‌بندد: هر دو گزارش، و ساختن و فهرست‌کردن خروجی. ورود داده (`POST /v1/events`) و ارسال پیام عمداً باز می‌مانند.

مهلت گزارش ۴۵ ثانیه است. توجه کنید که `GET /v1/capabilities` عدد `query_timeout_sec` را ۳۰ اعلام می‌کند و آن عدد مهلت گزارش‌ها نیست. هیچ‌کدام از سقف‌های تحلیلی (۱۲ مرحله، ۶۰ دوره، ۱۲۰ کوهورت، ۷۳۰ روز، ۴۵ ثانیه) روی هیچ نقطه‌ی پایانی منتشر نمی‌شوند. باید در کد خودتان بنویسیدشان.

## خروجی گرفتن {#exports}

خروجی یک کار پس‌زمینه است، نه یک پاسخ. `POST /v1/exports` کار را در صف می‌گذارد و ۲۰۲ برمی‌گرداند. `GET /v1/exports` وضعیت را می‌گوید. هیچ وب‌هوکی، هیچ فراخوان برگشتی و هیچ اعلانی وجود ندارد؛ تنها راه، پرسیدن دوباره است.

دسترسی لازم `data.export` است و جداست چون خروجی از ساختمان بیرون می‌رود: دسترسی خواندن داخل داشبوردی که هر کوئری را لاگ می‌کند، ریسک دیگری است تا فایل CSV آدرس ایمیل همه‌ی مشتری‌ها روی لپ‌تاپ یک نفر. نقش‌های `owner` و `admin` و `marketer` و `analyst` این دسترسی را دارند؛ `viewer` و `approver` و `finance` ندارند.

### ساختن کار خروجی {#export-queue}

```bash
curl -X POST https://api.segmentic.net/v1/exports \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
        "kind": "events",
        "format": "ndjson",
        "spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"}
      }'
```

```json
{"id": 42, "status": "queued", "kind": "events", "expires_after_hours": 168}
```

`kind` دقیقاً یکی از این چهارتاست: `events`، `profiles`، `segment`، `messages`. هر چیز دیگری ۴۲۲ می‌گیرد با کد `export_kind_invalid`.

`format` یا دقیقاً رشته‌ی `csv` است، یا هر چیز دیگری که **بی‌سروصدا به `ndjson` تبدیل می‌شود**. اگر `"parquet"` بفرستید، ۲۰۲ می‌گیرید و فایل NDJSON تحویل می‌گیرید. دلیل پیش‌فرض بودن NDJSON این است که خروجی رویدادها با ویژگی‌های تودرتو مستطیل نیست و صاف‌کردنش در CSV، تودرتویی را بی‌صدا از بین می‌برد.

`spec` شیء آزادی است که فقط سه کلیدش خوانده می‌شود:

| کلید | نوع | برای کدام `kind` | پیش‌فرض |
|---|---|---|---|
| `from` | رشته‌ی RFC3339 | `events`، `messages` | ۹۰ روز پیش |
| `to` | رشته‌ی RFC3339 | `events`، `messages` | همین حالا |
| `segment_id` | عدد | `segment` | اجباری؛ صفر یعنی شکست قطعی |

`profiles` بازه را کلاً نادیده می‌گیرد. بازه‌ی وارونه به‌جای خطا جابه‌جا می‌شود. هر چیز غیرقابل‌خواندنی در `spec` نادیده گرفته می‌شود و پیش‌فرض اعمال می‌شود.

نصبی که فضای ذخیره‌ی خروجی نداشته باشد به‌جای ۲۰۲ کد **۵۰۳** می‌دهد. روی چنین نصبی مسیر دانلود اصلاً ثبت نمی‌شود، پس ۲۰۲ یعنی وعده‌ی فایلی که هیچ‌جا برای برداشتنش نیست، و کار تا ابد در صف می‌ماند و شبیه کاری در جریان به نظر می‌رسد. ۵۰۳ و نه ۴۰۰، چون درخواست سالم بوده و نصب سالم نیست، و همین تفاوت است که می‌گوید کد خودتان را درست کنید یا از مدیر سیستم بخواهید.

**فیلد `columns` وجود ندارد، `max_rows` وجود ندارد، `limit` وجود ندارد.** ستون‌ها ثابت‌اند و سقف سطر یک ثابت سروری است.

### پیگیری {#export-poll}

```bash
curl https://api.segmentic.net/v1/exports \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "data": [
    {
      "id": 42, "kind": "events", "format": "ndjson",
      "spec": {"from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z"},
      "status": "ready", "rows_written": 412334, "bytes": 91203344,
      "location": "/var/lib/segmentic/exports/7/42.ndjson",
      "attempts": 1, "truncated": false,
      "expires_at": "2026-06-08T09:12:00Z",
      "requested_by": "api-key:3",
      "created_at": "2026-06-01T09:04:00Z",
      "finished_at": "2026-06-01T09:12:00Z"
    }
  ],
  "has_more": false
}
```

پنج وضعیت ممکن است: `queued`، `running`، `ready`، `failed`، `expired`. هیچ نقطه‌ی پایانی این فهرست را منتشر نمی‌کند.

- `location` مسیر فایل روی سرور ماست و روی سیم می‌آید. برای شما بی‌مصرف است.
- `attempts` منتشر می‌شود چون «شکست خورد» و «سه بار شکست خورد و دست کشید» دو جواب متفاوت‌اند. سقف تلاش ۳ است.
- `truncated` می‌گوید کوئری روی سقف پنج میلیون سطر متوقف شده، نه در انتهای داده، پس فایل همه‌ی نتیجه نیست. وقتی نادرست باشد اصلاً فرستاده نمی‌شود. نتیجه‌ای که دقیقاً پنج میلیون سطر باشد از یک نتیجه‌ی بریده‌شده قابل تشخیص نیست و بریده‌شده گزارش می‌شود: فایل کاملی که اشتباهاً ناقص برچسب بخورد یک کوئری هزینه دارد، و فایل بریده‌ای که کامل برچسب بخورد عددی است در یک گزارش که بی‌صدا غلط است.
- کاری که در وضعیت `running` بماند و ادعایش بیشتر از ۴۵ دقیقه قدیمی باشد، دوباره برداشته می‌شود، پس ورکری که کشته شده کار را برای همیشه گیر نمی‌اندازد. ساختن یک فایل حداکثر ۳۰ دقیقه وقت دارد.
- `expired` یعنی جاروکش فایل را پاک کرده و سطر را نگه داشته، تا سابقه‌ی اینکه خروجی گرفته شد و چه کسی خواستش، از خود فایل بیشتر عمر کند.

> [!warn]
> پاسخ اصلاً کلید `next_cursor` ندارد، پس کلاینتی که آن را بخواند به‌جای رشته تهی هیچ‌چیز می‌گیرد، و `has_more` هرگز `true` نمی‌شود. هیچ‌جای API مقداری به `next_cursor` نمی‌دهد، پس رفتار پاکت صفحه روی هر مسیری که آن را برمی‌گرداند همین است و ویژه خروجی‌ها نیست. پارامتر `cursor` خوانده و دور ریخته می‌شود. `limit` پیش‌فرض ۲۵ است و روی ۱۰۰ سقف می‌خورد. یعنی فقط ۱۰۰ کار آخر، از جدید به قدیم، در دسترس‌اند و کارهای قدیمی‌تر از راه API قابل دیدن نیستند.

`GET /v1/exports/{id}` برای دیدن یک کار تنها، **وجود ندارد**. تنها راه، گرفتن فهرست و پیدا کردن شناسه در آن است.

### گرفتن فایل {#export-download}

`GET /v1/exports/{id}/download` روی میزبان مدیریت **وجود ندارد**. فقط روی گوش‌دهنده‌ی کنترلی داشبورد ثبت شده، که عمداً از بیرون شبکه‌ی داخلی قابل آدرس‌دهی نیست.

نتیجه‌اش را صریح می‌گوییم: یکپارچه‌سازی شما می‌تواند از راه `api.segmentic.net` خروجی بسازد و هیچ راه برنامه‌ای برای برداشتن بایت‌هایش ندارد. هیچ نشانی امضاشده‌ای، هیچ فضای ذخیره‌سازی شیئی و هیچ فیلد `download_url` در کل کد وجود ندارد. فایل را باید یک آدم از پنل بردارد، از «گزارش‌ها» و تب «خروجی‌ها». همان صفحه خروجی هم در صف می‌گذارد، پس یک خروجی موردی اصلاً به کلید API احتیاج ندارد.

اگر برنامه‌ی شما هفتگی به داده‌ی خام نیاز دارد، تا وقتی این مسیر باز نشده، خروجی صف‌شده جواب شما نیست.

### ستون‌های هر خروجی {#export-contents}

هر منبع، فهرست ستونش را صریح می‌نویسد و از `SELECT *` استفاده نمی‌کند، چون خروجی فایلی است که از ساختمان بیرون می‌رود و `SELECT *` یعنی روزی که کسی ستونی با شناسه‌ی هش‌شده یا پرچم داخلی اضافه کند، آن ستون بی‌صدا در دانلود بعدی همه‌ی مشتری‌ها ظاهر می‌شود.

`events`، ۲۳ ستون به همین ترتیب: `message_id`، `type`، `name`، `user_id`، `anonymous_id`، `session_id`، `event_time`، `received_at`، `revenue`، `currency`، `props_str`، `props_num`، `app_version`، `device_type`، `os_name`، `country`، `region`، `city`، `page_url`، `page_path`، `utm_source`، `utm_medium`، `utm_campaign`.

`profiles`، ۲۷ ستون: `user_id`، `email`، `phone`، `first_name`، `last_name`، `gender`، `city`، `region`، `country`، `language`، `timezone`، `device_type`، `os_name`، `app_version`، `traits`، `traits_num`، `has_push`، `has_email`، `has_phone`، `push_opt_in`، `email_opt_in`، `sms_opt_in`، `total_events`، `total_revenue`، `order_count`، `first_seen`، `last_seen`.

`ip` و `national_id` عمداً در خروجی پرونده‌ها نیستند: خروجی، نسخه‌ای از داده است که کمترین محافظت را دارد، و کد ملی در یک فایل اکسل روی لپ‌تاپ کسی، بدترین سطری است که این پایگاه داده می‌تواند از دست بدهد.

`messages`، ۱۸ ستون از Postgres: `message_id`، `user_id`، `channel`، `category`، `transport`، `campaign_id`، `journey_id`، `node_id`، `variant`، `topic_id`، `status`، `reason`، `gateway`، `gateway_id`، `delivery`، `delivery_detail`، `delivered_at`، `sent_at`.

`segment`، ۲۰ ستون: `user_id`، `email`، `phone`، `first_name`، `last_name`، `gender`، `city`، `region`، `country`، `total_events`، `total_revenue`، `order_count`، `first_seen`، `last_seen`، `has_push`، `has_email`، `has_phone`، `push_opt_in`، `email_opt_in`، `sms_opt_in`. نقشه‌ی ویژگی‌ها اینجا نیست چون کلیدهایش برای هر کاربر فرق می‌کند و بدون اسکن کل مخاطب، به مجموعه‌ی ثابتی از ستون تبدیل نمی‌شود.

خروجی سگمنت همان کامپایلری را استفاده می‌کند که پیش‌نمایش داشبورد استفاده می‌کند، تا فایل و پیش‌نمایش نتوانند درباره‌ی اینکه چه کسی در مخاطب هست اختلاف پیدا کنند.

### قالب و رمزگذاری {#export-encoding}

NDJSON: هر سطر یک شیء JSON کامل، کلیددار با نام ستون، تا اضافه‌شدن یک ستون در آینده اندیس همه‌ی چیزهای پایین‌دست را یکی جابه‌جا نکند. زمان‌ها به‌صورت `RFC3339Nano` در UTC می‌آیند. نقشه‌ی خالی `{}` می‌شود، هرگز `null`. عددها عدد می‌مانند.

CSV: با BOM یونیکد شروع می‌شود، وگرنه اکسل روی ویندوز هر نام فارسی را درهم و ناخوانا نشان می‌دهد. سلولی که با `=` یا `+` یا `-` یا `@` یا تب یا CR شروع شود، یک تب جلویش گذاشته می‌شود، چون ویژگی‌های پرونده از کاربران نهایی خود مشتری می‌آید و کسی که فایل را باز می‌کند، کارمند مشتری ما است، روی لپ‌تاپ خودش، داخل شبکه‌ی خودش.

در CSV، بولی‌ها به کلمه‌ی بله و خیر ترجمه می‌شوند، اعداد اعشاری با نماد ساده نوشته می‌شوند نه نماد علمی، و زمان صفر سلول خالی می‌شود نه سال ۱۹۷۰ که مثل تاریخ واقعی به نظر می‌رسد و کسی ممکن است رویش تصمیم بگیرد.

نام ستون‌ها فقط در یک حالت فارسی است: `format` برابر `csv` و `kind` برابر `segment`. باقی ترکیب‌ها نام ماشینی می‌گیرند، چون NDJSON را لودری می‌خواند که روی نام فیلد کلید می‌زند و کلید JSON با متن فارسی، برای هر خط لوله‌ی پایین‌دست دشمن است.

`xlsx` از صف خروجی درنمی‌آید. فقط `csv` و `ndjson` رمزگذار دارند.

### سقف سطر و انقضا {#export-limits}

سقف پنج میلیون سطر روی هر چهار نوع اعمال می‌شود، به‌صورت `LIMIT` ساده در SQL. فراتر از آن، فایل چیزی نیست که کسی بازش کند؛ خط لوله‌ای است که باید مستقیم از انبار داده بخواند.

> [!warn]
> وقتی به سقف بخورد، کار `truncated: true` می‌گیرد و پنل روی همان سطر هشدار می‌گذارد. مدت درازی بی‌صدا بود: نه در سطر کار و نه در هیچ پاسخی چیزی نمی‌گفت که خروجی به سقف خورده، `rows_written` فقط همان عدد سقف را نشان می‌داد، و کسی به عددی که مشکوکانه رند است دقت نمی‌کند. اگر داده‌ی شما ممکن است به پنج میلیون نزدیک شود، بازه را خودتان تکه کنید و به این پرچم تکیه نکنید که بعدا خبرتان کند.

فایل بعد از ۷ روز (۱۶۸ ساعت) پاک می‌شود و همین عدد در پاسخ ۲۰۲ به‌صورت `expires_after_hours` منتشر می‌شود. فایلی که آدرس ایمیل همه‌ی مشتری‌ها را دارد و برای همیشه روی یک اشتراک می‌ماند، همان چیزی است که یک خروجی بی‌دقت را به نشت تبدیل می‌کند، و چون کسی یادش نمی‌ماند پاکش کند، پلتفرم پاک می‌کند.

مقصد فایل روی دیسک است، با دسترسی پوشه‌ی `0700` و فایل `0600`. پیاده‌سازی S3 یا فضای شیئی وجود ندارد؛ نصب‌های داخل سازمان یک والیوم متصل دارند و نقطه‌ی پایانی S3 ندارند.

## وارد کردن داده {#import}

> [!danger]
> وارد کردن CSV **فقط از پنل** ممکن است. روی `api.segmentic.net` هیچ نقطه‌ی پایانی چندبخشی برای آپلود فایل وجود ندارد. `POST /v1/imports` و `GET /v1/imports/{id}` وجود ندارند. آنچه هست همگام است، و در ادامه توضیح داده می‌شود تا بدانید پنل دقیقاً چه می‌کند.

هر سه نقطه‌ی پایانی ورود، دسترسی `profile.write` می‌خواهند، از جمله `inspect` که چیزی ذخیره نمی‌کند: آن هم مرحله‌ای است که فایل اکسل مشتری را می‌خواند. `viewer` و `analyst` و `approver` و `finance` خطای ۴۰۳ می‌گیرند؛ `marketer` قبول می‌شود.

### کاربران از CSV {#import-users}

`POST /v1/import/inspect` فایل را می‌خواند، حدس نگاشت را برمی‌گرداند و چیزی نمی‌نویسد:

```json
{
  "header": ["email", "موبایل", "امتیاز"],
  "preview": [["a@b.com", "09123456789", "1500"]],
  "total": 1,
  "mapping": {"columns": [
    {"index": 0, "field": "email", "name": "email"},
    {"index": 1, "field": "phone", "name": "موبایل"},
    {"index": 2, "field": "trait", "name": "امتیاز"}
  ]}
}
```

`preview` حداکثر ۵ سطر است و `total` تعداد سطرهای داده بدون سطرهای خالی.

`POST /v1/import/users` همان فایل را واقعاً وارد می‌کند. فرم چندبخشی با بخش فایل به نام `file`، به‌علاوه‌ی دو فیلد اختیاری: `mapping` که یک شیء JSON است و حدس را کاملاً کنار می‌گذارد، و `dry_run`.

> [!danger]
> `dry_run` پیش‌فرض **خاموش** است. فقط رشته‌ی دقیق `"true"` آن را روشن می‌کند و هر چیز دیگری خاموش است. اگر این فیلد را جا بیندازید، پرونده‌های واقعی نوشته می‌شوند و کمپین‌ها بعداً همان‌ها را هدف می‌گیرند.

```json
{
  "total": 2,
  "accepted": 1,
  "rejected": 1,
  "errors": [{"row": 3, "column": "موبایل", "value": "rubbish", "reason": "..."}],
  "truncated": false,
  "dry_run": false,
  "ingested": 1
}
```

`row` از یک شمرده می‌شود و سطر سرستون، سطر شماره یک است، پس اولین سطر داده، سطر شماره ۲ است. `truncated` می‌گوید فهرست خطاها سر ۵۰ تا بریده شده، تا «۵۰ مشکل» با «دقیقاً ۵۰ مشکل» اشتباه گرفته نشود.

`ingested` می‌تواند از `accepted` کمتر باشد، اگر باس بعضی را نپذیرفته باشد. وجود دارد چون گفتن «بیست هزار نفر وارد شد» وقتی نوزده هزارتا رسیده، همان دروغی است که یک هفته بعد به‌شکل کمپینی که به آدم‌های کمتری رسید، رو می‌شود.

خطاهای سطح فایل، ۴۰۰ با کد `invalid_file` می‌دهند: سرستون ندارد، سطر داده ندارد، هیچ ستونی به شناسه یا ایمیل یا موبایل نگاشت نشده، سطر بیشتر از حد، ستون بیشتر از حد، فایل انتخاب نشده، فایل بزرگ‌تر از حد. شکست جزئی، ۵۰۳ با کد `partial_import` می‌دهد و کل `result` را همراهش می‌فرستد، چون بخشی از فایل واقعاً داخل رفته و گفتن «همه‌اش شکست خورد» یعنی اپراتور فایل را دوباره آپلود می‌کند.

| سقف | عدد |
|---|---|
| سطر داده در هر فایل | ۵۰۰ هزار |
| ستون سرستون | ۱۰۰ |
| بایت در یک سلول | ۴۰۹۶ |
| حجم فایل | ۶۴ مگابایت (۶۷۱۰۸۸۶۴ بایت) |
| سطر خراب گزارش‌شده | ۵۰ |

`GET /v1/import/fields` فهرست فیلدها و دو عدد `max_rows` و `max_bytes` را منتشر می‌کند.

### نگاشت ستون {#import-mapping}

نگاشت `{"columns": [{"index": 0, "field": "email", "name": "email"}]}` است. ستون‌ها با **اندیس** آدرس داده می‌شوند نه با متن سرستون، چون فایل‌های اکسل مرتب سرستون تکراری یا خالی دارند و نقشه‌ای که با نام کلید بخورد، یکی از آن‌ها را بی‌صدا می‌اندازد.

فیلدهای ممکن: `user_id`، `email`، `phone`، `first_name`، `last_name`، `gender`، `birthday`، `national_id`، `city`، `region`، `country`، `language`، `trait`، `ignore`. وقتی `field` برابر `trait` باشد، `name` کلید همان ویژگی است.

حدس خودکار، سرستون‌های فارسی و انگلیسی را با هم می‌شناسد، چون یک تیم بازاریابی ایرانی در یک هفته هم از CRM فارسی خروجی می‌گیرد و هم از یک ابزار تحلیلی خارجی. «شناسه» و «کد کاربر» و «ایمیل» و «رایانامه» و «موبایل» و «شماره تماس» و «نام خانوادگی» و «کد ملی» و «استان» و همتاهای انگلیسی‌شان شناخته می‌شوند. سرستون‌ها از تطبیق فارسی رد می‌شوند، پس شکل عربی «ی» و «ک» هم مثل شکل فارسی‌شان می‌خورد.

سرستونی که به هیچ‌چیز نخورد، ویژگی‌ای با کلید همان متن سرستون می‌شود. ستون دومی که همان هویت قبلی را ادعا کند هم ویژگی می‌شود، چون دو ستون `email` یعنی یکی از آن دو چیز دیگری است. سرستون خالی `ignore` می‌شود.

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

BOM ابتدای فایل حذف می‌شود، وگرنه `email` با یک نویسه‌ی نامرئی جلویش می‌رسد و بی‌صدا ویژگی سفارشی می‌شود. سطرهای ناهم‌طول تحمل می‌شوند و سطرهای خالی رد می‌شوند.

### تبدیل هر سطر {#import-rows}

خطای هر سطر جمع می‌شود و کار متوقف نمی‌شود: فایل بیست‌هزارسطری با سه شماره‌ی موبایل خراب باید نوزده هزار و نهصد و نود و هفت نفر را وارد کند و درباره‌ی آن سه تا حرف بزند.

| فیلد | قاعده |
|---|---|
| هر فیلد | سلول بلندتر از ۴۰۹۶ بایت، سطر را رد می‌کند |
| هر فیلد | سلول خالی کلاً رد می‌شود |
| `user_id` | ارقام فارسی به لاتین تا می‌شوند |
| `phone` | به شکل E.164 ذخیره می‌شود؛ نامعتبر، سطر را رد می‌کند |
| `email` | کوچک می‌شود و شکلش بررسی می‌شود؛ نامعتبر، سطر را رد می‌کند |
| `national_id` | رقم کنترلی بررسی می‌شود؛ نامعتبر، سطر را رد می‌کند |
| `birthday` | به `YYYY-MM-DD` گرگوری تبدیل می‌شود؛ نامعتبر، سطر را رد می‌کند |
| `gender` | به `male` یا `female` نرمال می‌شود؛ هرگز شکست نمی‌خورد |
| `trait` | مقدار عددی‌شکل عدد ذخیره می‌شود، باقی متن؛ هرگز شکست نمی‌خورد |

شماره‌ی موبایل همین‌جا نرمال می‌شود نه پایین‌دست، چون یک شماره که در دو شکل ذخیره شود، دو پرونده برای یک آدم است. `۰۹۱۲۳۴۵۶۷۸۹` به `+989123456789` تبدیل می‌شود.

جنسیت این کلمه‌ها را می‌شناسد: `male`، `m`، «مرد»، «آقا»، «پسر» و `female`، `f`، «زن»، «خانم»، «دختر».

ویژگی عددی: مقداری که با صفر شروع شود متن می‌ماند و مقداری بلندتر از ۱۵ نویسه هم متن می‌ماند، چون کد پستی `01234` که به ۱۲۳۴ تبدیل شود غلط است و کد ملی بزرگ‌تر از توان دقت اعشاری، رقم‌های آخرش را از دست می‌دهد. ارقام فارسی هم عدد حساب می‌شوند.

تاریخ هر دو تقویم و هر دو مجموعه‌ی رقم را می‌خواند، با جداکننده‌ی `/` یا `-` یا `.`. **سال کمتر از ۱۷۰۰ جلالی خوانده می‌شود** و به گرگوری تبدیل می‌شود؛ دو تقویم آن‌قدر از هم دورند که بازه‌ی مبهمی که آدم واقعاً تایپ کند وجود ندارد. `1370/05/12` می‌شود `1991-08-03`، و `1370/13/45` خطاست، چون مرز ماه و روز جلالی قبل از تبدیل بررسی می‌شود.

اگر `user_id` نباشد، ایمیل و بعد موبایل جایش را می‌گیرند. اگر هیچ‌کدام نباشند سطر رد می‌شود، چون یک identify بدون چیزی برای شناسایی، پرونده‌ی ناشناسی می‌سازد که هرگز کسی به آن نمی‌رسد.

خروجی ورود، پاکت `identify` معمولی است، نه نوشتن مستقیم روی پرونده. اگر مستقیم می‌نوشت، دو راه واگرا برای ساختن یک سطر داشتیم، و اولین باری که پرونده‌ی وارد‌شده با پرونده‌ی ساخته‌شده‌ی SDK اختلاف پیدا می‌کرد، شماره‌ای که در یک مسیر `09123456789` و در مسیر دیگر `+989123456789` ذخیره شده، کسی نمی‌توانست بگوید کدام مسیر غلط بوده. شناسه‌ی حساب همیشه از کلید می‌آید، هرگز از بار درخواست.

### رویدادهای گذشته {#import-events}

`POST /v1/import/events` رویداد با زمان گذشته می‌نویسد. این هم فقط از پنل در دسترس است و عمداً روی کالکتور نیست: کالکتور با کلید نوشتن احراز می‌شود، و کلید نوشتن طبق طراحی داخل اپ موبایل و باندل سایت می‌رود، یعنی هر کسی که سورس را ببیند یکی دارد. اعتبارنامه‌ی عمومی‌ای که بتواند رویداد با زمان دلخواه گذشته بنویسد، اعتبارنامه‌ای است که می‌تواند قیف رقیب را بازنویسی کند.

```json
{"events": [
  {"type": "track", "event": "order_completed", "user_id": "u1",
   "timestamp": "2025-04-02T10:00:00Z"}
]}
```

- بدنه حداکثر ۶۴ مگابایت.
- آرایه‌ی خالی، خطای ۴۰۰.
- بیشتر از ۱۰ هزار رویداد در هر درخواست، خطای ۴۰۰ و هیچ‌چیز به باس نمی‌رسد.
- مهلت ۱۰ دقیقه.
- `dry_run` روی این نقطه‌ی پایانی وجود ندارد. فیلدش در پاسخ هست ولی هیچ‌چیز آن را ست نمی‌کند.

پنجره‌ی مجاز از سیاست نگه‌داری همان حساب می‌آید. اگر روزهای نگه‌داری رویداد صفر باشد، یعنی نگه‌داری همیشگی، پنجره ۳۶۵۰ روز است. اگر خواندن سیاست شکست بخورد، پنجره به ۳۰ روز برمی‌گردد. برای معنی این عدد، [داده‌های شخصی](/docs/privacy) را ببینید.

تفاوت اصلی با ورود زنده همین‌جاست: ورود زنده زمان بیرون از پنجره را بی‌صدا روی لبه‌ی پنجره می‌گذارد و ۲۰۰ می‌دهد، که یک سال سفارش را به یک روز غول‌پیکر تبدیل می‌کند. این نقطه‌ی پایانی سطر را رد می‌کند و شماره‌ی سطرش را می‌گوید.

```json
{
  "total": 10000, "accepted": 9997, "rejected": 3,
  "errors": [{"row": 412, "reason": "..."}],
  "truncated": false, "dry_run": false,
  "oldest": "2024-03-01T08:00:00Z",
  "newest": "2026-05-01T21:30:00Z"
}
```

`oldest` و `newest` بازه‌ای است که واقعاً نوشته شد، تا اپراتور قبل از اجرای دسته‌ی بعدی نیم‌میلیونی مطمئن شود ورود جایی نشسته که منظورش بود. فهرست خطا اینجا سر ۱۰۰ تا بریده می‌شود، نه ۵۰. شکست جزئی، ۵۰۳ با کد `partial_backfill`.

برای ورود همزمان از سرور خودتان، `POST /v1/events` روی همان میزبان مدیریت هست و تا ۵۰۰ رویداد در هر فراخوان می‌گیرد. **تاریخچه را از آن مسیر نبرید.** قواعد این صفحه آنجا اعمال نمی‌شوند: پنجره‌ی ثابت ۳۰ روز است، سیاست نگه‌داری حساب خوانده نمی‌شود، و هر زمانی قدیمی‌تر از ۳۰ روز بی‌صدا روی لبه‌ی همان پنجره نوشته می‌شود و پاسخ ۲۰۰ است. یعنی یک سال سفارش، یک روز غول‌پیکر می‌شود و هیچ‌چیز در پاسخ نمی‌گوید. [ارسال از سرور](/docs/server) را ببینید.

## آنچه فقط در پنل هست {#panel-only}

این‌ها ساخته شده‌اند، کار می‌کنند و تست دارند، ولی هیچ آدرسی روی `api.segmentic.net` ندارند:

- تحلیل مسیر. `POST /v1/reports/paths` وجود ندارد.
- امتیاز ریزش، درگیری و RFM، هم خلاصه و هم فهرست اعضا.
- کاوش رویداد و نمای کلی حساب.
- سری زمانی سناریو و کمپین.
- گزارش‌های زمان‌بندی‌شده و دیباگر رویداد.
- فهرست قیف‌های ذخیره‌شده. `GET/POST /v1/funnels` و بقیه‌ی CRUD آن فقط روی کنترل‌پلین‌اند؛ [فهرست قیف‌های ذخیره‌شده](/docs/reports#funnel-library) را ببینید.
- موتور داشبوردساز، یعنی `/v1/widgets/query` و `funnel` و `cohort` و `validate`. رندر یک داشبورد ذخیره‌شده هم وجود ندارد.
- گزارش پیام‌های پنل با جست‌وجوی بازه زمانی، کمپین، کاربر، گیرنده، شناسه پیام، کانال، نتیجه و آزمایشی بودن. خروجی کامل فیلترشده از `GET /v1/messages.csv` و `GET /v1/messages.json` به شکل جریانی دریافت می‌شود و به صفحهٔ فعلی محدود نیست.
- خروجی دفتر مالی و خروجی گزارش ممیزی.
- `GET /v1/segments/{id}/export` که تنها جایی است که xlsx تولید می‌شود. این مسیر جریانی است و سقفش یک میلیون سطر، باز هم بی‌صدا. عیب شناخته‌شده‌ای هم دارد که در خود کد نوشته شده: کد ۲۰۰ و هدرها قبل از خواندن اولین سطر فرستاده می‌شوند، پس شکست وسط جریان نمی‌تواند به ۵۰۳ تبدیل شود و به‌جایش اتصال قطع می‌شود. هیچ شمارش سطری و هیچ پرچم کامل‌بودنی روی سیم نیست.
- وارد کردن CSV و بک‌فیل رویداد، که بالاتر شرحشان آمد.

## آنچه ممکن نیست {#not-possible}

فهرست صادقانه‌ی کارهایی که با کلید API نمی‌شود کرد:

- برداشتن بایت‌های یک خروجی صف‌شده.
- خواندن وضعیت یک کار خروجی با شناسه‌اش.
- رفتن جلوتر از ۱۰۰ کار خروجی آخر.
- گرفتن xlsx از صف خروجی.
- انتخاب ستون یا سقف سطر برای خروجی.
- خواندن سقف‌های تحلیلی به‌صورت برنامه‌ای. `GET /v1/reports/limits` وجود ندارد و `GET /v1/capabilities` هیچ‌کدامشان را منتشر نمی‌کند.
- گرفتن جمع‌بندی تحویل و درگیری پیام. `GET /v1/reports/messages` وجود ندارد.
- کشف مقدارهای یک ویژگی. هیچ نقطه‌ی پایانی مقدارهای متمایز یک ویژگی را فهرست نمی‌کند؛ `GET /v1/schema/events` فقط کلیدها را می‌دهد.
- فهمیدن اینکه نام رویدادی که نوشته‌اید غلط است.
- انتخاب زبان پاسخ. `Accept-Language` روی این میزبان اصلاً پارس نمی‌شود و همه‌ی متن‌های محلی‌شده فارسی‌اند.
- گرفتن کد خطای متمایز برای گزارش نامعتبر یا خرابی انبار داده.
- دریافت فراخوان برگشتی در پایان یک خروجی یا یک ورود. وب‌هوکی برای این دو وجود ندارد.

برای شکل خطاها و کدها، [خطاها](/docs/errors) را ببینید. برای سقف‌ها، [محدودیت‌ها](/docs/limits).

---

# مرجع API

> دو سطح، دو نوع کلید، دو میزبان. این صفحه می‌گوید کدام کار با کدام‌یک انجام می‌شود.
> https://segmentic.net/docs/api

سگمنتیک روی دو میزبان جواب می‌دهد. این دو هیچ چیز مشترکی ندارند: نه کلید، نه شکل درخواست، نه شکل خطا. درخواستی که به میزبان اشتباه برود با خطای احراز هویت رد می‌شود و آن خطا چیزی درباره اشتباه شما نمی‌گوید، پس اولین چیزی که باید درست بدانید این است که هر کار روی کدام میزبان انجام می‌شود.

> نمودار: فراخوان‌ها، کلیدها و مرز اعتماد در API ورود داده و API مدیریتی سگمنتیک

## دو سطح {#surfaces}

| | ورود داده | مدیریتی |
|---|---|---|
| میزبان | `https://in.segmentic.net` | `https://api.segmentic.net` |
| کلید | `wk_seg_...` | `sk_seg_...` |
| کلید کجا زندگی می‌کند | داخل اپ شما: باندل جاوااسکریپت، APK، IPA | روی سرور خودتان، در یک متغیر محیطی |
| چه کسی صدایش می‌زند | دستگاه کاربران شما | بک‌اند خودتان، یک اسکریپت، یک عامل هوش مصنوعی |
| چه کار می‌کند | رویداد و دستگاه و نشانی را می‌نویسد | آنچه در حساب هست را می‌خواند و عوض می‌کند |
| مرجع | [نقاط ورود داده](/docs/api/ingest) | [API مدیریتی](/docs/api/management) |

پنل، یعنی `https://app.segmentic.net`، میزبان سوم است و یک وب‌سایت است نه API. آن APIای که پنل با آن حرف می‌زند روی listener جداگانه‌ای است که اصلا از اینترنت مسیریابی نمی‌شود، و دلیلش این است: دو مسیری که در بدنه پاسخشان یک اعتبارنامه متنی برمی‌گردانند آنجا زندگی می‌کنند، و نبودن یک آدرس بهتر از چکی است که ممکن است کسی یادش برود بنویسد.

هر مسیر روی هر دو سطح زیر `/v1/` است. نه بخش `/api` وجود دارد و نه هیچ پیشوند دیگری.

## دو نوع کلید {#keys}

هر دو کلید ۳۲ بایت از منبع تصادفی سیستم‌عامل‌اند، با base64url بدون padding کد شده‌اند و یک پیشوند جلویشان دارند. پس یک کلید واقعی `wk_seg_` یا `sk_seg_` است به‌علاوه ۴۳ کاراکتر. از هر کلید فقط هش `SHA-256` ذخیره می‌شود، و به همین دلیل هیچ‌کدام بار دوم به شما نشان داده نمی‌شوند: یک دامپ لو رفته دیتابیس نباید یک انبار کلید کارآمد باشد.

دو پیشوند از هم جدایند تا یک نشتی در یک نگاه دسته‌بندی شود. `wk_` داخل یک باندل عمومی همان چیزی است که باید باشد. `sk_` در همان جا یک حادثه امنیتی است.

| | کلید نوشتن `wk_seg_` | کلید API `sk_seg_` |
|---|---|---|
| چه چیزی را معرفی می‌کند | یک اپ داخل یک حساب | یک کلید داخل یک حساب، با یک نقش |
| مجوزها | هیچ، و هیچ مجوزی هم ممکن نیست | هرچه نقشش می‌دهد |
| داده یک آدم را می‌خواند | نه، جز صندوق درون‌برنامه‌ای خود همان آدم که کلید دومی می‌خواهد | بله، اگر نقش اجازه بدهد |
| عمومی است | بله، عمدا | نه. راز است |
| انقضا | ندارد | پیش‌فرض ۳۶۵ روز، هنگام ساخت انتخاب می‌شود |
| ابطال | برای هر اپ، در پنل | برای هر کلید، در پنل |

کلید نوشتن به یک حساب، یک اپ و محیط همان اپ (`development` یا `staging` یا `production`) ترجمه می‌شود. همین. نمی‌تواند پرونده‌ای بخواند، سگمنتی فهرست کند، مخاطبی بشمارد یا پیامی بفرستد، و هیچ تنظیمی هم نیست که چنین اجازه‌ای بدهد.

کلید API به یک نقش ترجمه می‌شود: `admin`، `marketer`، `analyst`، `viewer`، `approver` یا `finance`. نقش مجوزها را تعیین می‌کند و مجوزها مسیرها را. `GET /v1/whoami` فهرست موثر را برمی‌گرداند، و پاسخ `403` در فیلد `need` دقیقا نام همان مجوزی را می‌آورد که نداشتید، تا کسی مجبور نشود برای فهمیدن اینکه کدام مجوز را باید بدهد تیکت بزند.

## گرفتن هر کلید {#getting-keys}

کلید نوشتن از پنل می‌آید، از صفحه «SDK و اتصال». اپ را انتخاب کنید، کلید بسازید، متن کلید را بردارید. هر اپ کلید خودش را دارد تا ابطال یکی بقیه را ساکت نکند، و متن کلید فقط یک بار روی صفحه می‌آید و بعد از آن هرگز قابل بازیابی نیست.

کلید API از پنل می‌آید، از «تنظیمات» و بعد «کلیدهای API». نقش را هنگام ساخت انتخاب می‌کنید و کلید تا آخر عمرش همان نقش را دارد. سه قاعده که همان صفحه اعمال می‌کند: نقش `owner` رد می‌شود، پس هیچ کلیدی هرگز نمی‌تواند حساب را منتقل یا حذف کند؛ کلید نمی‌تواند از سازنده‌اش بالاتر باشد؛ و انقضای خالی ۳۶۵ روز می‌شود، نه «هرگز».

هیچ‌کدام از این دو کلید از طریق API مدیریتی ساخته نمی‌شوند. هر دو مسیر ساخت روی listener خود داشبورد هستند که عمدا از بیرون مسیریابی نمی‌شود، پس ساختن اعتبارنامه کاری است که یک آدم واردشده در پنل انجام می‌دهد. این عمدی است و قرار نیست شل شود.

> [!note]
> کلید محدودشده (scoped) کار نمی‌کند. دیتابیس روی هر کلید ستون `scopes` دارد، مسیر خواندن هم آن را می‌خواند، و هیچ‌جای محصول هرگز در آن نمی‌نویسد. پس هر کلید تمام نقشش را دارد و `GET /v1/whoami` همیشه `"scoped": false` جواب می‌دهد. باریک‌ترین کلیدی که واقعا می‌توانید بسازید، باریک‌ترین نقش است. سقف گیرنده و سقف ردیف اطلاعات شخصی روی همان جدول هم همین وضع را دارند: ستون هست، کدی که بخواندش نیست.

## کلید API هرگز به دست کلاینت نمی‌رسد {#never-in-the-client}

کلید نوشتن عمدا عمومی است. داخل جاوااسکریپت شما می‌رود، هرکسی می‌تواند آن را از سورس صفحه بخواند، و این پذیرفتنی است چون بدترین کاری که یک غریبه با آن می‌کند اضافه‌کردن نویز به داده خود شماست، که هم دیده می‌شود و هم قابل تعمیر است.

کلید API برعکس است. می‌تواند مخاطب‌های شما را بشمارد، شماره تلفن مشتری‌هایتان را خروجی بگیرد و به همه‌شان پیام بفرستد. جایش روی سرور شماست، در یک متغیر محیطی، و هیچ جای دیگری نیست. نه داخل اپ موبایل، که از باینری بیرون کشیده می‌شود. نه داخل مرورگر، که یک view-source با آن فاصله دارد. نه داخل فایل کانفیگ بیلد اپ موبایل، که همان کار است با یک قدم اضافه.

جابه‌جا فرستادن این دو آن‌قدر رایج است که هر دو جهت جواب مشخصی دارند، و فقط یکی از آن دو به شما می‌گوید چه اشتباهی کرده‌اید.

کلید `wk_` که به میزبان مدیریتی برود، `401` با کد مخصوص خودش می‌گیرد:

```json
{
  "error": {
    "code": "write_key_rejected",
    "message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
  }
}
```

این رد شدن پیش از هر مراجعه به دیتابیس اتفاق می‌افتد، فقط از روی پیشوند.

کلید `sk_` که به میزبان ورود داده برود، `401` با همان جواب همیشگی می‌گیرد:

```json
{"status":"error","message":"invalid write key"}
```

کالکتور اصلا به پیشوند نگاه نمی‌کند. هرچه گرفته را هش می‌کند و هش را در جدول کلیدهای نوشتن می‌گردد، و کلید API آنجا نیست، پس از کلیدی که باطل شده یا هرگز وجود نداشته قابل تشخیص نیست. این یکی‌شدن عمدی است: این نقطه نباید ابزاری برای فهمیدن اینکه چه کلیدهایی وجود دارند بشود. هزینه‌اش این است که این جهت هیچ سرنخی به شما نمی‌دهد، و اگر به `invalid write key` خیره شده‌اید با کلیدی که مطمئنید درست است، اول پیشوندش را نگاه کنید.

## کدام کار روی کدام میزبان {#which-surface}

| کار | میزبان | مسیر |
|---|---|---|
| ثبت اینکه یک آدم چه کرد | ورود داده | `POST /v1/track` |
| گذاشتن ویژگی روی یک پرونده | ورود داده | `POST /v1/identify` |
| ثبت دیده‌شدن یک صفحه یا یک اسکرین | ورود داده | `POST /v1/page`، `POST /v1/screen` |
| چسباندن تاریخچه ناشناس به آدم واردشده | ورود داده | `POST /v1/alias` |
| فرستادن چند رویداد با هم از دستگاه | ورود داده | `POST /v1/batch` |
| فرستادن رویداد از سرور خودتان | مدیریتی | `POST /v1/events` |
| ثبت یک دستگاه برای پوش | ورود داده | `POST /v1/devices` |
| مشترک‌کردن یک مرورگر در وب‌پوش | ورود داده | `POST /v1/webpush/subscribe` |
| وصل‌کردن چت بله، ایتا یا روبیکا | ورود داده | `POST /v1/messenger/link` |
| خواندن صندوق درون‌برنامه‌ای کاربر واردشده | ورود داده | `POST /v1/inbox` |
| گرفتن کمپین‌های روی سایت برای یک صفحه | ورود داده | `GET /v1/onsite` |
| اعتبارسنجی یا شمارش مخاطب | مدیریتی | `POST /v1/audiences/validate`، `POST /v1/audiences/count` |
| ساخت، تغییر یا حذف سگمنت | مدیریتی | `POST /v1/segments`، `PUT /v1/segments/{id}`، `DELETE /v1/segments/{id}` |
| ساخت کمپین و بعد فرستادنش | مدیریتی | `POST /v1/campaigns`، `POST /v1/campaigns/{id}/send` |
| گرفتن گزارش قیف یا ماندگاری | مدیریتی | `POST /v1/reports/funnel`، `POST /v1/reports/retention` |
| فرستادن یک پیام تراکنشی | مدیریتی | `POST /v1/messages` |
| صف‌کردن یک خروجی | مدیریتی | `POST /v1/exports` |
| پرسیدن اینکه یک کلید چه می‌تواند بکند | مدیریتی | `GET /v1/whoami` |
| پرسیدن اینکه این نصب چه چیزهایی را سرو می‌کند | مدیریتی | `GET /v1/capabilities` |
| بررسی بالا بودن یک میزبان | هر دو | `GET /v1/status` |

> [!warn]
> `POST /v1/batch` روی میزبان ورود داده و `POST /v1/events` روی میزبان مدیریتی هر دو یک آرایه از رویداد می‌گیرند و به‌جای هم استفاده نمی‌شوند. کلید آرایه در یکی `batch` است و در دیگری `events`. اولی `200` جواب می‌دهد و با `message_id` تکراری‌ها را حذف می‌کند؛ دومی `202` جواب می‌دهد و اصلا تکراری‌ها را حذف نمی‌کند، پس بسته‌ای که دوباره فرستاده شود دو بار شمرده می‌شود. دومی هر زمانی قدیمی‌تر از ۳۰ روز را هم به‌جای رد کردن، دقیقا روی مرز ۳۰ روز پیش می‌چسباند، که مهاجرت تاریخچه را بی‌صدا خراب می‌کند. هر دو در صفحه خودشان کامل توضیح داده شده‌اند.

## قراردادهای مشترک {#conventions}

**JSON در هر دو جهت.** هر پاسخ روی هر دو سطح با `Content-Type: application/json; charset=utf-8` می‌آید. هیچ‌کدام از دو سطح نوع محتوای خود درخواست را بررسی نمی‌کند: هر دو بدنه را می‌خوانند و هرچه ادعا شده باشد، آن را JSON می‌خوانند. با این حال `application/json` بفرستید، چون همان چیزی است که روزی بررسی خواهد شد.

**زمان‌ها فقط `RFC 3339`.** هر فیلد زمانی روی سیم، یعنی `timestamp` و `sent_at` و `scheduled_at` و `from` و `to` در بازه گزارش، با دیکودر استاندارد JSON زبان Go خوانده می‌شود که فقط `RFC 3339` را می‌پذیرد. ثانیه یونیکس، میلی‌ثانیه یونیکس و تاریخ خالی مثل `2026-08-06` هیچ‌کدام دیکود نمی‌شوند، و روی میزبان ورود داده این یعنی کل درخواست با `malformed JSON` رد می‌شود. زمان را UTC بفرستید.

**متن UTF-8 است و فارسی هنگام ورود یکدست می‌شود.** حرف‌های عربی شبیه‌به‌فارسی به شکل فارسی برگردانده می‌شوند، یعنی `ي` به `ی` و `ك` به `ک`، اعراب و کشیده حذف می‌شوند و فاصله‌های عجیب به یک فاصله ساده جمع می‌شوند. نیم‌فاصله، بزرگی و کوچکی حروف لاتین و ارقام فارسی دقیقا همان‌طور که فرستاده شده‌اند می‌مانند. به همین دلیل است که سگمنتی روی «شهر = تهران» کاربری را هم می‌گیرد که کیبوردش عربی بوده.

**شناسه‌هایی که می‌فرستید رشته‌اند و شناسه‌هایی که برمی‌گردانیم عدد.** `user_id` و `anonymous_id` و `message_id` رشته‌هایی حداکثر ۲۵۶ بایتی‌اند و هیچ قاعده قالبی ندارند. شناسه سگمنت، کمپین یا خروجی در JSON عدد صحیح بدون علامت است، و شکل داخل مسیر باید عدد صحیح مثبت باشد وگرنه جواب `400` است.

**هیچ چیزی در بدنه نمی‌تواند حساب را عوض کند.** هر دو سطح حساب را از روی اعتبارنامه برمی‌دارند و هرچه بدنه گفته باشد را بازنویسی می‌کنند. `tenant_id` داخل بدنه یک سگمنت رد نمی‌شود، فقط هرگز خوانده نمی‌شود.

## دو پوشش خطا {#errors}

میزبان ورود داده خطا را در همان پوششی می‌دهد که موفقیت را می‌دهد:

```json
{"status":"error","message":"request body too large"}
```

میزبان مدیریتی یک شیء با کد پایدار می‌دهد:

```json
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
    "need": "data.export"
  }
}
```

`code` قرارداد است. `message` قرارداد نیست، و هر یکپارچه‌سازی که روی متن پیام شرط بگذارد اولین باری که جمله بهتر نوشته شود می‌شکند. `details` روی بعضی خطاهای اعتبارسنجی می‌آید و همان تکه از payload شما را که مشکل دارد نشان می‌دهد. `need` فقط روی `403` می‌آید.

> [!danger]
> روی میزبان مدیریتی این پوشش یکدست نیست، برخلاف کامنتی که در خود سورس می‌گوید هست. یازده تا از بیست‌ودو مسیر از هندلرهایی استفاده می‌کنند که برای پنل نوشته شده بودند و `{"error":"count unavailable"}` جواب می‌دهند، یعنی `error` رشته است نه شیء، و دو مسیر گزارش به فارسی جواب می‌دهند. محتاطانه parse کنید: `error` را بخوانید، ببینید رشته است یا شیء، و هرگز فرض نکنید `error.code` وجود دارد. مرجع [API مدیریتی](/docs/api/management) می‌گوید کدام مسیر کدام شکل را می‌دهد.

## صفحه‌بندی {#pagination}

صفحه‌بندی به معنای واقعی وجود ندارد، و این همان حفره‌ای است که بیشتر از همه ممکن است یک بعدازظهر از شما بگیرد.

`?limit=` دقیقا روی یک مسیر خوانده می‌شود، `GET /v1/exports`، که پیش‌فرضش ۲۵ است و روی ۱۰۰ سقف می‌خورد. `?cursor=` همان‌جا خوانده می‌شود و بعد دور ریخته می‌شود. بقیه مسیرهای فهرست هر دو پارامتر را نادیده می‌گیرند. هیچ هندلری `next_cursor` تولید نمی‌کند، پس این کلید به‌جای اینکه باشد و تهی بماند، اصلا در بدنه هیچ پاسخی نیست، و `has_more` همیشه `false` است، حتی وقتی ردیف بیشتری هست.

`GET /v1/segments` و `GET /v1/campaigns` از «بدون صفحه‌بندی» هم بدترند: کوئری پشتشان به `ORDER BY updated_at DESC LIMIT 200` ختم می‌شود و هیچ‌چیز در پاسخ این را نمی‌گوید. نه تعدادی هست، نه `has_more`، نه هشداری. حسابی که ۲۵۰ سگمنت دارد، ۲۰۰ تای تازه‌تر را می‌گیرد و روی هیچ سطحی مسیری نیست که به آن ۵۰ تای دیگر برسد. این دو فهرست را «۲۰۰ تای آخر» بخوانید و هرچه لازم دارید را با شناسه‌اش قابل دسترس نگه دارید.

## سقف‌ها و بودجه {#limits}

**میزبان ورود داده هیچ محدودیت نرخی ندارد.** نه در ثانیه، نه در دقیقه، نه به‌ازای کلید، نه به‌ازای IP. تنها کنترل حجم روی آن سهمیه ماهانه صورتحساب است، و وقتی آن تمام شود جواب `402 Payment Required` است با یک جمله فارسی و رد شدن کل درخواست. SDK نباید `402` را دوباره امتحان کند: تا کسی پول ندهد چیزی عوض نمی‌شود.

**میزبان مدیریتی با وزن اندازه می‌گیرد، نه با تعداد.** هر کلید در هر دقیقه تقویمی ۶۰۰ واحد دارد. تماسی که چیزی نمی‌خواند ۱ واحد است، یک کوئری محدود روی انبار داده ۵ واحد، و اسکنی که با تاریخچه شما بزرگ می‌شود ۲۵ واحد. بودجه به‌ازای کلید است نه حساب، تا یک عامل هوش مصنوعی که در حلقه افتاده نتواند بودجه‌ای را که خط لوله سفارش‌های شما به آن وابسته است تمام کند. تمام‌شدن بودجه `429` است با `Retry-After: 60`:

```json
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```

روی بودجه هیچ هدر `X-RateLimit-*` وجود ندارد و `GET /v1/whoami` هم نمی‌گوید چقدر مانده، پس کلاینت تا وقتی رد نشده نمی‌بیند چقدر نزدیک است. تنها هدرهای نرخ روی کل این سطح، `X-RateLimit-Limit` و `X-RateLimit-Remaining` روی `POST /v1/messages` هستند، و آن‌ها محدودکننده دوم و جداگانه‌ای را توصیف می‌کنند که به‌ازای حساب است و درخواست می‌شمارد نه وزن.

**بودجه بسته شکست می‌خورد.** اگر شمارنده در دسترس نباشد جواب `503` با کد `budget_unavailable` است، حتی روی `GET /v1/whoami` و `GET /v1/capabilities`، چون آن‌ها هم بودجه خرج می‌کنند. `GET /v1/status` تنها مسیری است که از آن قطعی جان سالم به در می‌برد. این جهت عمدی است: یک عامل بی‌اندازه‌گیری که در حلقه افتاده گران‌تر از گزارشی است که منتظر می‌ماند.

## بررسی بالا بودن یک میزبان {#status}

هر دو میزبان `GET /v1/status` را بدون اعتبارنامه، بدون خواندن دیتابیس و بدون محدودیت نرخ سرو می‌کنند.

```bash
curl -i https://in.segmentic.net/v1/status
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z

{"status":"ok","service":"collector","version":"1.42.0"}
```

`service` روی میزبان ورود داده `collector` است و روی میزبان مدیریتی `api`، تا یک صفحه وضعیت بتواند برای هر مولفه یک چراغ نشان بدهد. `version` مهر بیلد است، که «آیا استقرار رفته بالا» را از بیرون قابل جواب‌دادن می‌کند. `X-Server-Time` هست تا پروبی که زمان رفت‌وبرگشت را می‌سنجد بتواند «ما کندیم» را از «مسیر بین شما و ما کند است» جدا کند. بدنه هرگز کش نمی‌شود، چون صفحه وضعیتی که وسط یک قطعی یک `ok` کش‌شده را از CDN می‌خواند از نبودن صفحه وضعیت بدتر است.

## توسعه محلی {#local}

کالکتور به‌طور پیش‌فرض روی `http://localhost:8080` گوش می‌دهد (متغیر `HTTP_ADDR`). هرچه روی میزبان ورود داده هست، آنجا با یک کلید نوشتن از حساب محلی شما کار می‌کند.

API مدیریتی روی هر آدرسی که `PUBLIC_API_ADDR` بگوید گوش می‌دهد، و این متغیر **به‌طور پیش‌فرض خالی است**، یعنی تا کسی مقدارش را نگذارد API مدیریتی اصلا سرو نمی‌شود. نشانه‌اش connection refused است نه `404`، و اولین چیزی است که وقتی یکپارچه‌سازی سمت سرور شما به هیچ‌جا نمی‌رسد باید ببینید. استقرار آن را به `:8082` می‌بندد، که همان آدرسی است که سرور MCP هم وقتی `SEGMENTIC_API_URL` تنظیم نشده باشد فرض می‌کند.

`https://in.segmentic.ir` میزبان ورود داده نیست و عمدا به میزبان فعلی ریدایرکت نمی‌شود. SDKای که هنوز به نام قدیمی اشاره می‌کند باید با صدای بلند خراب شود، نه اینکه کار کند.

## چیزهایی که اینجا نیستند {#absent}

این‌ها را می‌نویسیم چون فهمیدنشان از راه امتحان‌کردن گران‌تر است:

- **هیچ راهی برای ساخت، فهرست‌کردن یا ابطال کلید از طریق API نیست.** هر دو نوع کلید فقط از پنل ساخته می‌شوند.
- **هیچ راهی برای متوقف‌کردن یک کمپین نیست.** `POST /v1/campaigns/{id}/send` روی میزبان مدیریتی هست؛ مکث، ادامه و لغو نیستند. وقتی بک‌اند شما ارسالی را زمان‌بندی کرد، فقط پنل می‌تواند جلویش را بگیرد.
- **هیچ راهی برای دانلود خروجی نیست.** می‌توانید یکی را صف کنید و فهرست کنید. فایل از پنل برداشته می‌شود.
- **هیچ راهی برای تایید یک کمپین یا خواندن اینکه تایید شده یا نه نیست.** ثبت درخواست تایید مسیر دارد، خود تصمیم ندارد. یکپارچه‌سازی جواب را با دوباره امتحان‌کردن ارسال و خواندن کد `409` می‌فهمد.
- **جست‌وجوی وضعیت یک پیام وجود ندارد.** مسیری به شکل `GET /v1/messages/{idempotency_key}` نیست.
- **خواندن پرونده وجود ندارد.** روی هیچ‌کدام از دو میزبان `GET /v1/profiles/{user_id}` نیست.
- **مسیرهای سناریو، قالب، رضایت، حاکمیت و ممیزی** روی سطح عمومی نیستند.
- **هیچ `PATCH`ای در کار نیست.** `PUT /v1/segments/{id}` کل شیء را جایگزین می‌کند، بدون `If-Match` و بدون هیچ نشانه نسخه، پس دو نویسنده هم‌زمان بی‌صدا کار همدیگر را پاک می‌کنند.
- **هیچ idempotencyای جز روی `POST /v1/messages` نیست.** تایم‌اوتی که روی `POST /v1/segments` دوباره امتحان شود، سگمنت دوم می‌سازد.
- **مکان‌یابی جغرافیایی از روی IP نیست.** `country` و `region` و `city` فقط از چیزی پر می‌شوند که SDK در `context.location` می‌فرستد.
- **روی میزبان مدیریتی preflight مربوط به CORS نیست.** روی آن mux هیچ پاسخ‌دهنده `OPTIONS`ای ثبت نشده، پس preflight مرورگر به مسیر پیش‌فرض می‌افتد و `404` می‌گیرد و خود درخواست اصلا فرستاده نمی‌شود. مرورگر اصلا نمی‌تواند API مدیریتی را صدا بزند. همین وضع مطلوب است: کلید `sk_seg_` جایش داخل صفحه نیست.
- **متد اشتباه روی میزبان مدیریتی `405` نیست.** `PUT /v1/campaigns/5` به مسیر پیش‌فرض می‌افتد و `404 unknown_endpoint` می‌گیرد. روی میزبان ورود داده برعکس است: مسیر ثبت‌نشده زیر `/v1/` جواب `405` می‌گیرد، چون الگوی preflight مربوط به CORS کل آن پیشوند را برای خودش برداشته است.

---

# مرجع: نقاط ورود داده

> هر مسیر روی میزبان ورود داده، با درخواست و پاسخ کامل و قابل کپی.
> https://segmentic.net/docs/api/ingest

این صفحه هر مسیر روی میزبان ورود داده است، همان میزبانی که دستگاه کاربران شما با آن حرف می‌زند. کلید نوشتن عمومی می‌گیرد، نوشتن را می‌پذیرد، و تنها چیزی که تا به حال برمی‌گرداند یا عمومی است (فهرست کمپین‌های روی سایت) یا با یک اعتبارنامه دوم محافظت می‌شود (صندوق درون‌برنامه‌ای). برای مخاطب و کمپین و گزارش و ارسال، [API مدیریتی](/docs/api/management) را ببینید.

## میزبان {#host}

```text
https://in.segmentic.net
```

روی ماشین خودتان، کالکتور به `http://localhost:8080` گوش می‌دهد.

کل کار کالکتور این است: اعتبارسنجی کن، تکراری‌ها را بردار، سریع تحویل بده، و هرگز به یک SDK نگو داده را دور بریزد چون مشکلی سمت ما هست. همین یک جمله بیشتر کدهای وضعیت پایین را توضیح می‌دهد: قطعی سمت ما یک `503` است که SDK دوباره امتحان می‌کند، نه یک `401` که آن را دائمی بفهمد.

`https://in.segmentic.ir` نام قدیمی است و عمدا به اینجا ریدایرکت نمی‌شود. SDKای که هنوز به آن اشاره می‌کند عمدا خراب می‌شود، نه اینکه ظاهرا کار کند.

## احراز هویت با کلید نوشتن {#authentication}

کلید نوشتن شکل `wk_seg_` است به‌علاوه ۴۳ کاراکتر base64url. می‌شود آن را در سه جا گذاشت و به همین ترتیب خوانده می‌شوند؛ اولین جایی که پیدا شود برنده است:

1. `Authorization: Bearer wk_seg_...`
2. `X-Segmentic-Key: wk_seg_...`
3. `?write_key=wk_seg_...` در کوئری‌استرینگ

شکل عادی همان هدر است. پارامتر کوئری برای این هست که یک image beacon و یک فراخوان `navigator.sendBeacon` نمی‌توانند هدر بگذارند، و SDK وب همین را برای `GET /v1/onsite` به کار می‌برد تا آن درخواست یک GET ساده بین‌دامنه‌ای بماند و preflight نخواهد.

از هدر `Authorization` فقط دقیقا پیشوند `Bearer ` (با B بزرگ و یک فاصله) برداشته می‌شود. هر طرح دیگری رد نمی‌شود بلکه به جای بعدی می‌افتد، پس `Authorization: Token wk_seg_...` یعنی «اینجا توکن bearer نیست» و آن هدر کلا نادیده گرفته می‌شود.

کلید به یک حساب، یک اپ و محیط همان اپ ترجمه می‌شود. حساب و اپ روی هر رویداد از آن کلید مهر می‌خورند و هرگز از بدنه گرفته نمی‌شوند، پس payloadای که نام حساب دیگری را ببرد هیچ اثری ندارد. محیط روی هیچ‌چیز مهر نمی‌خورد: روی همان اعتبارنامه حل‌شده می‌ماند، هیچ ستونی در رویداد نگهش نمی‌دارد و هیچ‌چیز روی این میزبان آن را نمی‌خواند. جست‌وجو بر اساس هش `SHA-256` است و هیچ‌جا پیشوند بررسی نمی‌شود، و به همین دلیل کلید API که به اینجا فرستاده شود صرفا یک کلید نوشتن ناشناخته است.

کلیدهای حل‌شده یک دقیقه کش می‌شوند (`WRITE_KEY_CACHE`)، و شکست‌ها هم همین‌طور، چون اپی که با کلید غلط منتشر شده باشد وگرنه تا ابد دیتابیس را می‌کوبد.

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

## وقتی احراز هویت شکست می‌خورد {#auth-errors}

| وضعیت | کد | بدنه | هدر |
|---|---|---|---|
| کلید در هیچ‌کدام از سه جا نیست | `401` | `{"status":"error","message":"missing write key"}` | |
| کلید ناشناخته، باطل‌شده، یا حساب معلق | `401` | `{"status":"error","message":"invalid write key"}` | |
| خود جست‌وجو شکست خورد | `503` | `{"status":"error","message":"cannot verify the write key right now; retry"}` | `Retry-After: 5` |

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

ردیف سوم قبلا `401` جواب می‌داد و آن بدترین جواب ممکن بود. SDK کد `401` را «این کلید هیچ‌وقت کار نخواهد کرد» می‌فهمد، می‌ایستد و رویدادهای بافرشده را دور می‌ریزد؛ `503` را «بعدا امتحان کن» می‌فهمد و نگهشان می‌دارد. این را اندازه گرفتیم نه اینکه درباره‌اش استدلال کنیم: با دیتابیسی که به صفر اسکیل شده بود، هشت رویداد از هشت رویداد `401` گرفتند. لاگ پیش‌نوشت (WAL) دقیقا برای این هست که یک خرابی زیرساخت هیچ رویدادی را از بین نبرد، و همان یک خط شکستش داد، چون درخواست اصلا به لاگ نمی‌رسید.

## CORS {#cors}

هر نقطه‌ای که کلید نوشتن می‌خواهد، این هدرها را قبل از هر کاری می‌نویسد، حتی قبل از احراز هویت، تا مرورگر کد وضعیت واقعی را ببیند نه یک خطای CORS روی `401` یا `413`:

```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Segmentic-Key
Access-Control-Max-Age: 86400
```

`OPTIONS` روی هر مسیری زیر `/v1/` جواب `204` می‌دهد با همان هدرها و بدون بدنه.

`Access-Control-Allow-Credentials` هرگز ست نمی‌شود، و همین است که origin آزاد را بی‌خطر می‌کند. کلید نوشتن تنها اعتبارنامه‌ای است که این میزبان می‌پذیرد و عمدا عمومی است، پس هیچ اعتبارنامه محیطی‌ای نیست که مرورگر خودکار بچسباند و هیچ چیزی نیست که origin آزاد لو بدهد. کوکی به اینجا نفرستید؛ خوانده نمی‌شود.

`GET /v1/status` و نقاط ایمیلی زیر `/e/` هدر CORS ندارند. آن‌ها از داخل صفحه صدا زده نمی‌شوند.

## بدنه درخواست {#body}

JSON، تا ۵ مگابایت (`5242880` بایت) در هر درخواست. بیشتر از آن `413` است با `{"status":"error","message":"request body too large"}`.

`Content-Type` بررسی نمی‌شود. هندلرها بدنه را می‌خوانند و هرچه درخواست ادعا کرده باشد آن را JSON می‌خوانند، پس `text/plain` با بدنه JSON امروز کار می‌کند. با این حال `application/json` بفرستید.

هرچه JSON معتبر نباشد `400` است با `{"status":"error","message":"malformed JSON"}`. توجه کنید که این شامل بدنه سالمی هم می‌شود که یک زمان بدشکل دارد: `timestamp` و `sent_at` فقط به شکل `RFC 3339` دیکود می‌شوند، پس `"timestamp": 1786000000` کل درخواست را به‌عنوان JSON خراب رد می‌کند، نه به‌عنوان یک فیلد غلط.

## فیلدهای بدنه رویداد {#event-fields}

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

| فیلد | نوع | لازم | توضیح |
|---|---|---|---|
| `event` | رشته | فقط روی `/v1/track` | بعد از یکدست‌سازی حداکثر ۱۲۸ بایت. فارسی اشکالی ندارد، فاصله‌ها می‌مانند، حروف کوچک و بزرگ عوض نمی‌شوند و قاعده snake_case وجود ندارد |
| `user_id` | رشته | یکی از `user_id` یا `anonymous_id` | حداکثر ۲۵۶ بایت |
| `anonymous_id` | رشته | یکی از آن دو | حداکثر ۲۵۶ بایت، بدون قاعده قالب، لازم نیست UUID باشد |
| `previous_id` | رشته | فقط روی `/v1/alias` | شناسه‌ای که ادغام می‌شود. **هیچ سقف طولی ندارد**، برخلاف دو تای بالا |
| `message_id` | رشته | نه، ولی بفرستید | حداکثر ۲۵۶ بایت. بدون آن، تلاش دوباره به‌عنوان تکراری شناخته نمی‌شود |
| `timestamp` | `RFC 3339` | نه | پیش‌فرض همان لحظه‌ای است که ما دریافت کردیم |
| `sent_at` | `RFC 3339` | نه | اصلاح اختلاف ساعت را ممکن می‌کند |
| `properties` | شیء | نه | حداکثر ۲۵۶ کلید، هر کلید حداکثر ۱۲۸ بایت، مقدار رشته‌ای حداکثر ۸۱۹۲ بایت |
| `traits` | شیء | نه | حداکثر ۲۵۶ کلید، با همان سقف مقدار |
| `context` | شیء | نه | پایین‌تر |
| `type` | رشته | اینجا نادیده گرفته می‌شود | نوع را مسیر تعیین می‌کند. فقط روی آیتم‌های بسته لازم است |

`type` در بدنه یک درخواست تک‌رویدادی با مسیر بازنویسی می‌شود، پس `POST /v1/track` هر چیزی هم که بدنه بگوید فقط می‌تواند رویداد track بسازد. این خطای اعتبارسنجی نیست؛ فیلد فقط جایگزین می‌شود.

سقف ۲۵۶ بایت به سه شکل متفاوت اعمال می‌شود و همین تفاوت گاز می‌گیرد. `user_id` و `anonymous_id` وقتی بلند باشند **رد می‌شوند**، با `id_too_long`. `context.session_id` روی ۲۵۶ بایت **بریده می‌شود**، بی‌صدا، پس شناسه نشست بلند تبدیل به یک شناسه نشست دیگر می‌شود. `previous_id` هیچ‌کدام نیست: کامل ذخیره می‌شود و تنها چیزی که محدودش می‌کند سقف ۵ مگابایتی بدنه است.

کلیدهای ویژگی و خصیصه یکدست می‌شوند: فضای اضافه بریده می‌شود، کاراکترهای کنترلی حذف می‌شوند، بعد هر دسته فاصله و هر `.` و `-` به یک `_` تبدیل می‌شود و زیرخط ابتدا و انتها برداشته می‌شود. پس `"  spaced  key "` می‌شود `spaced_key`، و `dotted.key` می‌شود `dotted_key` و `dashed-key` می‌شود `dashed_key`. کلیدی که بعد از یکدست‌سازی خالی شود، کلا رد می‌شود.

مقدار ویژگی‌ها هرجا معنا داشته باشد دو بار ذخیره می‌شود: همیشه به شکل متن، و وقتی عدد یا بولین باشد به شکل عدد هم. رشته‌ای که شبیه عدد است هرگز به عدد تبدیل نمی‌شود، چون تبدیل `"01234"` صفر ابتدایی یک کد پستی را دور می‌ریزد و یک کد ملی بزرگ، رقم‌های آخرش را به دقت عدد اعشاری می‌بازد. ویژگی با مقدار `null` به‌جای ذخیره‌شدن به شکل رشته خالی، کلا حذف می‌شود، تا فیلتر «مقدار ندارد» درست بماند.

## شیء `context` {#context}

`context` دستگاه و اپ و صفحه را توصیف می‌کند. بخشی از آن روی رویداد ذخیره می‌شود و بخشی عمدا نمی‌شود، و این تفاوت مهم است چون روی چیزی که اصلا نگه داشته نشده نمی‌شود فیلتر گذاشت.

| فیلد | ذخیره می‌شود به‌عنوان | سقف |
|---|---|---|
| `context.app.version` | `app_version` | ۶۴ |
| `context.device.type` | `device_type` | ۳۲ |
| `context.device.model` | `device_model` | ۱۲۸ |
| `context.device.manufacturer` | `device_vendor` | ۶۴ |
| `context.device.push_provider` | `push_provider` | ۱۶ |
| `context.os.name` و `context.os.version` | `os_name` (با حروف کوچک) و `os_version` | هرکدام ۳۲ |
| `context.network.carrier` | `carrier` | ۶۴ |
| `context.page.url` و `.path` و `.referrer` | `page_url` و `page_path` و `page_referrer` | هرکدام ۲۰۴۸ |
| `context.page.title` | `page_title` | ۵۱۲ |
| `context.campaign.source` و `.medium` و `.name` و `.term` و `.content` | `utm_source` و `utm_medium` و `utm_campaign` و `utm_term` و `utm_content` | هرکدام ۱۲۸ |
| `context.campaign.campaign_id` و `.journey_id` | `campaign_id` و `journey_id` | عددی |
| `context.campaign.variant_id` و `.message_id` و `.token` | `variant_id` و `source_message_id` و `sg_t` | ۶۴ و ۲۵۶ و ۱۲۸ |
| `context.locale` و `.timezone` و `.session_id` | `locale` و `timezone` و `session_id` | ۳۲ و ۶۴ و ۲۵۶ |
| `context.location.country` و `.region` و `.city` | `country` و `region` و `city` | هرکدام ۶۴ |
| `context.ip` | `ip`، **فقط** وقتی از خود اتصال چیزی نگرفته باشیم | ۶۴ |
| `context.user_agent` | هیچ. هدر `User-Agent` برنده است | |
| `context.screen.width` و `.height` و `.density` | هیچ. پذیرفته و دور ریخته می‌شود | |
| `context.location.latitude` و `.longitude` | هیچ. پذیرفته و دور ریخته می‌شود | |
| `context.device.id` و `.name` و `.push_token` و `.has_gms` و `.ad_tracking_enabled` | هیچ. پذیرفته و دور ریخته می‌شود | |

`context.device.push_token` عمدا دور ریخته می‌شود. فقط مسیر نگه داشته می‌شود و هرگز خود توکن: توکن پوش داخل جریان رویدادها یعنی کپی‌شدنش در انبار داده و هر خروجی و هر بکاپ، برای مقداری که رجیستری دستگاه‌ها همین حالا مالکش است. توکن را با `POST /v1/devices` ثبت کنید.

کلاینت نمی‌تواند IP، user agent، نام مرورگر، پرچم ربات یا حساب را تعیین کند. این‌ها از خود اتصال و از کلید می‌آیند، چون کلاینت نباید بتواند موقعیت جغرافیایی یا دستگاه خودش را جعل کند. چیزهایی که SDK درباره دستگاه می‌فرستد بر آنچه از هدر `User-Agent` استخراج می‌شود مقدم است؛ هدر فقط جاهای خالی را پر می‌کند. ترافیک ربات پرچم می‌خورد و ذخیره می‌شود، هرگز حذف نمی‌شود، چون حذف بی‌صدای آن یک افت ترافیک را غیرقابل‌توضیح می‌کند؛ هر گزارشی به‌طور پیش‌فرض آن را کنار می‌گذارد.

در بیلد مستقرشده مکان‌یابی جغرافیایی از روی IP وجود ندارد. `country` و `region` و `city` فقط از `context.location` پر می‌شوند.

## شکل پاسخ {#response}

هر پاسخ JSON روی این میزبان از این فیلدها ساخته می‌شود:

| فیلد | نوع | کی هست |
|---|---|---|
| `status` | `"ok"` یا `"error"` | همیشه |
| `accepted` | عدد | وقتی صفر نباشد |
| `duplicates` | عدد | وقتی صفر نباشد |
| `rejected` | عدد | وقتی صفر نباشد |
| `warnings` | آرایه‌ای از `{code, field, note}` | وقتی هشداری باشد |
| `errors` | آرایه‌ای از `{index, reason}` | وقتی آیتمی از بسته رد شده باشد |
| `message` | رشته | روی خطا |

`accepted` وقتی صفر باشد حذف می‌شود، پس بسته‌ای که کاملا رد شده باشد اصلا کلید `accepted` ندارد، و پاسخ خطا هم ندارد. آن را «اگر نبود یعنی صفر» بخوانید.

`duplicates` آن بخشی از `accepted` است که از قبل داشتیم. از `accepted` کم نمی‌شود، چون `accepted` به تنها سوالی جواب می‌دهد که یک SDK می‌پرسد، یعنی «می‌توانم دیگر نفرستم»، و کلاینتی که هرچه پذیرفته نشده را دوباره بفرستد، یک تکراری را تا ابد می‌فرستد. پس `accepted: 500` همراه `duplicates: 493` یعنی هفت رویداد ذخیره شد و ۴۹۳ تا رویدادی بودند که از قبل داشتیم. نبودنش هم مثل بقیه یعنی صفر.

هشدار یعنی رویداد با یک اصلاح پذیرفته شد. کدهایی که ممکن است ببینید:

| کد | معنی |
|---|---|
| `generated_message_id` | `message_id` نیامده بود، پس ما یکی ساختیم و تلاش دوباره برای این رویداد به‌عنوان تکراری شناخته نمی‌شود |
| `timestamp_in_future` | ساعت دستگاه بیش از یک ساعت جلو بود؛ روی زمان دریافت چسبانده شد |
| `timestamp_too_old` | قدیمی‌تر از پنجره ورود داده حساب شما؛ روی لبه همان پنجره چسبانده شد |
| `too_many_properties` | بیش از ۲۵۶ ویژگی؛ متن هشدار می‌گوید چند تا آمده و چند تا نگه داشته شده |
| `unserialisable_property` | یک ویژگی قابل کدگذاری نبود؛ `field` نامش را می‌گوید |
| `too_many_traits` | بیش از ۲۵۶ خصیصه |
| `invalid_phone` | شماره موبایل ایرانی معتبر نیست؛ مقدار خام همان‌طور که آمده ذخیره شد |
| `invalid_national_id` | کد ملی رقم کنترلش را رد کرد؛ **خصیصه حذف شد** |

به تفاوت دو تای آخر دقت کنید، که عمدی است: شماره بد نگه داشته می‌شود چون اغلب شماره واقعی با قالب غیرمنتظره است، و کد ملی بد حذف می‌شود چون کد ملی‌ای که رقم کنترلش را رد کند کد ملی نیست.

## POST /v1/track {#track}

ثبت می‌کند که یک آدم چه کرد. `event` لازم است؛ بدون آن جواب `400 missing_event_name` است.

```bash
curl -X POST https://in.segmentic.net/v1/track \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "m-1",
    "event": "order_completed",
    "user_id": "u_123",
    "properties": { "revenue": 2500000, "currency": "IRR", "order_id": "8821", "city": "تهران" }
  }'
```

```json
{"status":"ok","accepted":1}
```

همان فراخوان بدون `message_id` این جواب را می‌دهد:

```json
{
  "status": "ok",
  "accepted": 1,
  "warnings": [
    {
      "code": "generated_message_id",
      "field": "message_id",
      "note": "no message_id sent; retries of this event cannot be de-duplicated"
    }
  ]
}
```

درآمد از ویژگی‌ها استخراج می‌شود، به این ترتیب: اولین مقدار غیرصفر از `revenue` و `total` و `value`؛ اگر نبود، `price` ضربدر `quantity` که در نبودش یک فرض می‌شود. `currency` پیش‌فرض `IRR` است و با حروف بزرگ ذخیره می‌شود، پس `"irt"` به شکل `IRT` می‌ماند. هیچ تبدیل نرخی حدس زده نمی‌شود.

## POST /v1/identify {#identify}

خصیصه‌ها را روی یک پرونده می‌گذارد. `event` نادیده گرفته می‌شود؛ نام رویداد ذخیره‌شده همیشه `identify` است.

```bash
curl -X POST https://in.segmentic.net/v1/identify \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "id-8821",
    "user_id": "u_123",
    "traits": {
      "email": "  Ali@Digikala.COM ",
      "phone": "0912 345 6789",
      "first_name": "علی",
      "city": "کرج",
      "gender": "مرد",
      "lifetime_value": 48200000,
      "is_subscriber": true,
      "referral_code": "0912345"
    }
  }'
```

```json
{"status":"ok","accepted":1}
```

آن payload به این تبدیل می‌شود:

| خصیصه | ذخیره‌شده | چرا |
|---|---|---|
| `email` | `ali@digikala.com` | فضای اضافه بریده و حروف کوچک می‌شود. هیچ اعتبارسنجی قالبی در کار نیست |
| `phone` | `+989123456789` به‌علاوه `phone_operator: "mci"` | هرچیزی جز `E.164` برای یک آدم دو پرونده می‌سازد. اپراتور از چهار رقم اول درمی‌آید و `0912` یعنی `mci` (همراه اول) |
| `city` | `کرج` | فارسی یکدست می‌شود، پس کیبورد عربی هم که `كرج` بفرستد همین‌جا می‌نشیند |
| `gender` | `male` | تا می‌شود و نگاشت می‌شود. `m` و `male` و `man` و `مرد` و `اقا` و `پسر` همه `male` می‌شوند؛ مجموعه زنانه `female` می‌شود؛ هرچیز دیگر `other` |
| `lifetime_value` | متن `48200000` و عدد `48200000` | خصیصه عددی دو بار نوشته می‌شود تا هم فیلتر «برابر» و هم «بزرگ‌تر از» کار کند |
| `is_subscriber` | متن `true` و عدد `1` | |
| `referral_code` | فقط متن `0912345` | رشته‌ای که شبیه عدد است هرگز تبدیل نمی‌شود، پس صفر ابتدایی زنده می‌ماند |

این دوبار نوشتن تزئینی نیست. بعد از این اضافه شد که یک حساب زنده با حدود ۱۱۵ هزار پرونده، برای هر خصیصه نقشه عددی خالی داشت، پس مخاطب «موجودی کلید ۱۰۰ یا بیشتر» هیچ‌کس را برنمی‌گرداند و «کمتر از ۱۰» همه را برمی‌گرداند، از جمله کاربری که موجودی‌اش ۴۲۸ بود. نه خطایی، نه هشداری، مخاطبی که مثل یک جواب خوانده می‌شود.

`national_id` با رقم کنترل ایرانی اعتبارسنجی می‌شود و اگر رد شود **حذف می‌شود**، با هشدار `invalid_national_id`. مقدار معتبر با ارقام فارسی و عربی‌اش تبدیل‌شده به اسکی و فاصله‌های دو سرش بریده ذخیره می‌شود، و در باقی چیزها همان‌طور که فرستادید. خود بررسی قبل از شمردن، خط تیره و فاصله را برمی‌دارد، هشت تا ده رقم را می‌پذیرد و مقدار کوتاه را فقط برای حساب خودش با صفر به ده رقم می‌رساند، پس `12345679` هشت‌کاراکتری ذخیره می‌شود، `001-234-5679` خط تیره‌هایش را نگه می‌دارد و `001 234 5679` فاصله‌های داخلی‌اش را نگه می‌دارد. اگر سگمنت‌ها و جوین‌های شما ده رقم انتظار دارند، همان شکل ده‌رقمی را بفرستید.

## POST /v1/page و POST /v1/screen {#page-screen}

بدنه یکی است. `event` اینجا اختیاری است: بدون آن نام رویداد ذخیره‌شده روی `/v1/page` می‌شود `page_viewed` و روی `/v1/screen` می‌شود `screen_viewed`.

```bash
curl -X POST https://in.segmentic.net/v1/page \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "p-4471",
    "anonymous_id": "a_9f21c0",
    "event": "product",
    "properties": { "sku": "DKP-118820" },
    "context": {
      "page": {
        "url": "https://shop.example.ir/p/118820?utm_source=sms",
        "path": "/p/118820",
        "title": "گوشی موبایل",
        "referrer": "https://www.google.com/"
      },
      "session_id": "s_20260807_01",
      "locale": "fa-IR"
    }
  }'
```

```json
{"status":"ok","accepted":1}
```

## POST /v1/alias {#alias}

تاریخچه ناشناس را به آدم واردشده می‌چسباند. `previous_id` لازم است؛ بدون آن جواب `400 missing_previous_id` است. نام رویداد ذخیره‌شده همیشه `alias` است.

```bash
curl -X POST https://in.segmentic.net/v1/alias \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"message_id":"al-1","user_id":"u_123","previous_id":"a_9f21c0"}'
```

```json
{"status":"ok","accepted":1}
```

SDKها این را خودکار روی اولین `identify` بعد از گشت‌وگذار ناشناس می‌فرستند، پیش از خود identify. اگر کلاینت خودتان را می‌نویسید، همین را کپی کنید: بدون alias کل تاریخچه پیش از ورود کاربر یتیم می‌شود و هر قیفی که از مرز ورود عبور کند عدد اشتباه گزارش می‌دهد.

## POST /v1/batch {#batch}

تا ۵۰۰ رویداد در یک درخواست. هر آیتم `type` خودش را دارد و اینجا این فیلد نادیده گرفته نمی‌شود بلکه تعیین‌کننده است: باید یکی از `track`، `identify`، `alias`، `page`، `screen` باشد.

```bash
curl -X POST https://in.segmentic.net/v1/batch \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sent_at": "2026-08-07T09:12:41Z",
    "context": { "locale": "fa-IR", "app": { "version": "5.2.1" } },
    "batch": [
      {"type":"track","message_id":"b1","event":"product_viewed","user_id":"u1"},
      {"type":"track","message_id":"b2","event":"checkout_started","user_id":"u2"},
      {"type":"identify","message_id":"b3","user_id":"u3","traits":{"phone":"09123456789"}}
    ]
  }'
```

```json
{"status":"ok","accepted":3}
```

`context` و `sent_at` سطح بالا پیش‌فرض‌اند: در هر آیتمی که مال خودش را نداشته باشد کپی می‌شوند، و مقدار خود آیتم هرگز بازنویسی نمی‌شود.

یک آیتم بد، آیتم‌های خوب را از بین نمی‌برد. پاسخ اندیس را می‌گوید تا منطق تلاش دوباره شما بتواند آیتم را بدون تطبیق محتوا پیدا کند:

```json
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [ { "index": 1, "reason": "missing_identity" } ]
}
```

دو شکست کل درخواست را رد می‌کنند، هر دو با `400`: آرایه `batch` خالی (`batch_empty`) و بیش از ۵۰۰ آیتم (`batch_too_large: 501 items, limit 500`). رد شدن به دلیل سهمیه هم کل بسته را می‌گیرد و هرگز بخشی از آن را نمی‌پذیرد، چون پذیرش نصفه SDK را از تشخیص اینکه کدام آیتم‌ها را باید دوباره بفرستد ناتوان می‌کند.

بسته‌ای با پنجاه رویداد یک رفت‌وبرگشت برای حذف تکراری و یک رفت‌وبرگشت برای انتشار هزینه دارد، نه پنجاه تا از هرکدام. آرایه `warnings` در پاسخ حدود پنجاه ورودی سقف دارد تا بسته‌ای با پانصد رویداد کمی غلط، یک مگابایت نصیحت جواب ندهد؛ همه هشدارها همچنان در متریک‌های حساب شمرده می‌شوند.

> [!warn]
> دیباگر زنده رویداد در پنل، رویدادهایی را که از `/v1/batch` می‌آیند نمی‌بیند. ضبط فقط روی مسیر تک‌رویدادی و مسیر وبهوک انجام می‌شود. هر SDK موبایلی بسته می‌فرستد و SDK وب هم همین‌طور، پس اگر به دیباگر نگاه می‌کنید و چیزی نمی‌بینید در حالی که `accepted` بالا می‌رود، دلیلش همین است.

## `message_id` و حذف تکراری {#dedupe}

`message_id` همان چیزی است که تلاش دوباره را بی‌خطر می‌کند. SDKها روی شبکه موبایل بی‌ثبات با شدت دوباره می‌فرستند، پس بدون آن شمارش خرید بی‌صدا دو برابر می‌شود.

- دامنه‌اش حساب شماست. دو حساب می‌توانند یک `message_id` مشترک داشته باشند بدون اینکه به هم بخورند.
- پنجره ۴۸ ساعت است. باید با فاصله از طولانی‌ترین تلاش دوباره SDK بیشتر باشد: کلاینت اندرویدی که یک روز رویدادها را آفلاین بافر کرده و بعد فرستاده، باید هنوز شناخته شود.
- تکراری `200` جواب می‌گیرد با `accepted: 1` و `duplicates: 1`، دقیقا مثل تحویل اول، چون SDKای که خطا بگیرد تا ابد دوباره می‌فرستد. داخل بسته هم تکراری‌ها به همان دلیل در `accepted` شمرده می‌شوند و کنارش در `duplicates` گزارش می‌شوند.
- شناسه پیام قبل از انتشار رویداد رزرو می‌شود و اگر انتشار شکست بخورد همان رزرو پس داده می‌شود، پس رویدادی که `503` گرفته، وقتی SDK دوباره می‌فرستدش، تکراری حساب نمی‌شود.
- تکراری صورتحساب نمی‌شود. SDKای که دوباره می‌فرستد برای ما یک جست‌وجوی کش هزینه دارد، نه یک خط فاکتور که شما سرش بحث کنید.
- اگر انبار حذف تکراری در دسترس نباشد، رویداد به هر حال پذیرفته و منتشر می‌شود. پذیرفتن یک تکراری احتمالی قطعا بهتر از گم‌کردن رویداد است: تکراری پایین‌دست قابل تعمیر است و داده نبوده نیست.

مکانیزم یک «بنویس اگر نبود» با زمان انقضاست، نه فیلتر بلوم، پس مثبت کاذب ندارد.

## زمان و اختلاف ساعت {#timestamps}

`timestamp` یعنی آن اتفاق کی افتاد، روی دستگاه. `sent_at` یعنی دستگاه کی درخواست را فرستاد. دومی همان چیزی است که اجازه می‌دهد اولی را اصلاح کنیم.

1. نبودن `timestamp` یعنی زمانی که ما دریافت کردیم. بدون هشدار.
2. `timestamp` بیش از یک ساعت جلوتر از ساعت ما، روی زمان دریافت چسبانده می‌شود با هشدار `timestamp_in_future`. زمان جلوتر از الان فقط از ساعت غلط دستگاه می‌آید، و رد نکردنش رویداد را در بازه‌هایی می‌گذارد که گزارش‌ها آن‌ها را نهایی کرده‌اند.
3. `timestamp` قدیمی‌تر از پنجره ورود داده حساب شما، روی لبه همان پنجره چسبانده می‌شود با هشدار `timestamp_too_old`. پنجره پیش‌فرض ۳۰ روز است؛ حسابی که رویدادها را بیشتر نگه می‌دارد پنجره بلندتری می‌گیرد. این عدد به‌ازای حساب است نه یک ثابت سراسری.
4. در غیر این صورت، اگر `sent_at` آمده باشد و با ساعت ما بیش از یک دقیقه فرق داشته باشد، کل اختلاف به `timestamp` اضافه می‌شود. مقدار اصلاح‌شده فقط وقتی استفاده می‌شود که هنوز داخل پنجره بیفتد. برای اصلاح هیچ هشداری داده نمی‌شود.

یک مثال کامل: ساعت دستگاه دو ساعت عقب است. می‌گوید رویداد ساعت ۰۸:۰۰ رخ داده و ساعت ۱۰:۰۰ فرستاده شده. ما ساعت ۱۲:۰۰ دریافت می‌کنیم. اختلاف دو ساعت است، پس زمان ذخیره‌شده ۱۰:۰۰ است نه ۰۸:۰۰.

> [!danger]
> ورود داده زنده همیشه می‌چسباند و هرگز زمان خارج از پنجره را رد نمی‌کند. یعنی مهاجرت تاریخچه از این میزبان، بی‌صدا هرچه قدیمی‌تر از پنجره است را روی یک لحظه روی هم می‌ریزد، `200` جواب می‌دهد و درست به نظر می‌رسد تا ماه‌ها بعد که یک قیف بی‌معنا شود. این سر یک حساب واقعی که دو سال تاریخچه منتقل می‌کرد اتفاق افتاده است. تاریخچه را از `/v1/track` یا `/v1/batch` نریزید.

## کدهای وضعیت روی نقاط رویداد {#status-codes}

| وضعیت | کد | بدنه |
|---|---|---|
| پذیرفته شد | `200` | `{"status":"ok","accepted":1}` و در صورت وجود `warnings` |
| پذیرفته شد و تکراری بود | `200` | یکسان |
| کلید نوشتن نیست | `401` | `{"status":"error","message":"missing write key"}` |
| کلید بد، باطل یا معلق | `401` | `{"status":"error","message":"invalid write key"}` |
| جست‌وجوی کلید شکست خورد، قطعی ماست | `503` | `{"status":"error","message":"cannot verify the write key right now; retry"}` |
| بدنه بیشتر از ۵ مگابایت | `413` | `{"status":"error","message":"request body too large"}` |
| بدنه JSON نیست | `400` | `{"status":"error","message":"malformed JSON"}` |
| حساب از سهمیه گذشته | `402` | `{"status":"error","message":"<یک جمله فارسی>"}` |
| اعتبارسنجی رویداد تکی شکست خورد | `400` | `{"status":"error","message":"<متن کامل دلیل>"}` |
| بسته خالی یا بیش از ۵۰۰ | `400` | `{"status":"error","message":"batch_empty"}` یا `"batch_too_large: 501 items, limit 500"` |
| بعضی آیتم‌های بسته بد بودند | `200` | `{"status":"ok","accepted":N,"rejected":M,"errors":[...]}` |
| هم گذرگاه و هم بافر دیسک شکست خوردند | `503` | `{"status":"error","message":"temporarily unavailable, please retry"}` |

دلایل رد کدهای پایداری‌اند، چون پنل آن‌ها را به فارسی نگاشت می‌کند و مشتری‌ها رویشان هشدار می‌گذارند: `unknown_type`، `missing_identity`، `missing_event_name`، `event_name_too_long`، `event_name_invalid_chars`، `id_too_long`، `missing_previous_id`، `batch_too_large`، `batch_empty`. دو تای آن‌ها مقدار خود شما را داخل پیام می‌آورند، مثل `unknown_type: "trak"`، پس روی پیشوند تطبیق بدهید نه روی تساوی.

ردیف آخر تنها موردی است که SDK باید به دلیل داده دوباره تلاش کند. قطعی گذرگاه به‌تنهایی آن را نمی‌سازد: کالکتور روی یک لاگ پیش‌نوشت محلی می‌افتد، پس پایین‌بودن صف اصلا برای شما دیده نمی‌شود. باید هر دو شکست بخورند. توجه کنید که این `503` هدر `Retry-After` ندارد؛ فقط `503` مربوط به جست‌وجوی کلید دارد.

پاسخ `402` صرف‌نظر از `Accept-Language` شما فارسی است. کالکتور مذاکره زبان ندارد: هیچ‌چیز درخواست را با یک زبان برچسب نمی‌زند، پس آن جمله هر بار به فارسی برمی‌گردد. روی کد وضعیت شرط بگذارید، نه روی متن.

## POST /v1/devices {#devices}

یک دستگاه را ثبت می‌کند تا کمپین بتواند به آن پوش بفرستد. فقط وقتی سرو می‌شود که پوش پیکربندی شده باشد.

```bash
curl -X POST https://in.segmentic.net/v1/devices \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "d_5f2a91c4",
    "user_id": "u_123",
    "platform": "android",
    "tokens": { "fcm": "cZ1x...:APA91b..." },
    "push_enabled": true,
    "has_gms": true,
    "app_version": "5.2.1",
    "manufacturer": "Samsung",
    "model": "SM-A546E",
    "os_name": "android",
    "os_version": "14",
    "locale": "fa-IR",
    "timezone": "Asia/Tehran",
    "sdk_name": "segmentic-android",
    "sdk_version": "1.4.0"
  }'
```

```json
{"status":"ok"}
```

| فیلد | نوع | لازم | توضیح |
|---|---|---|---|
| `device_id` | رشته | بله | حداکثر ۲۵۶ بایت |
| `user_id` | رشته | یکی از آن دو | |
| `anonymous_id` | رشته | یکی از آن دو | |
| `platform` | رشته | بله | `android`، `ios`، `web`، `windows`، `macos`، `linux` و نام‌های جایگزینی مثل `iphone`، `ipad`، `osx`، `darwin`، `win`، `browser`. مقدار `server` رد می‌شود |
| `tokens` | شیء | یک توکن قابل استفاده | نام حامل به توکن |
| `push_provider` و `push_token` | رشته | نه | شکل قدیمی تک‌مسیره. اگر هر دو بیایند `tokens` برنده است |
| `push_enabled` | بولین | نه | نبودنش یعنی روشن، تا SDK قدیمی کاربران خودش را ساکت نکند |
| `has_gms` | بولین | نه | نبودنش یعنی «نگفت»، که با `false` یکی نیست |
| `app_version`، `manufacturer`، `model`، `os_name`، `os_version`، `locale`، `timezone`، `sdk_name`، `sdk_version` | رشته | نه | هرکدام حداکثر ۲۵۶ بایت |

کدام حامل به کدام پلتفرم می‌رسد:

| پلتفرم | حامل‌ها |
|---|---|
| `android` | `fcm`، `bazaar`، `myket`، `mqtt` |
| `ios` | `apns`، `mqtt` |
| `web`، `windows`، `macos`، `linux` | `webpush` |

توکنی که روی حامل اشتباه بیاید به‌جای ذخیره‌شدن، بیرون نگه داشته می‌شود و هشدار می‌گیرد، چون بدون این بررسی حالت خرابی «خطا» نیست: کمپینی است که گزارش می‌دهد صد درصد فرستاده شد و هیچ‌چیز تحویل نمی‌دهد.

آن جدول می‌گوید ثبت چه چیزی را می‌پذیرد، نه اینکه به چه چیزی می‌شود تحویل داد، و دو سطرش امروز اصلا چیزی تحویل نمی‌دهند. **هیچ ارائه‌دهنده `mqtt`ای پیاده‌سازی نشده**: نام حامل یک ثابت است و در ترتیب اولویت روتر هم نشسته، و پشتش هیچ کدی چیزی نمی‌فرستد، پس دستگاه اندروید یا iOS که فقط توکن `mqtt` دارد بی‌مشکل ثبت می‌شود و هرگز در دسترس نیست. **`windows` و `macos` و `linux` هم در روتر پوش مسیری ندارند**: جدول اولویت به‌ازای پلتفرم روتر فقط `android` و `ios` و `web` را دارد، پس ثبت دسکتاپ ذخیره و شمرده می‌شود و هرگز چیزی برایش نمی‌رود. `fcm` یا `apns` یا `webpush` روی `web` ثبت کنید، و ثبت دسکتاپ یا `mqtt` را دفترداری بخوانید نه دسترس‌پذیری.

توکن‌های APNs در راه ورود تعمیر می‌شوند. APIهای قدیمی iOS توکن را به شکل `<a1b2 c3d4>` رشته می‌کنند، و فرستادن همان عینا برای همیشه از طرف اپل رد می‌شود، پس کروشه‌ها و فاصله‌ها حذف و مقدار با حروف کوچک ذخیره می‌شود.

شکست‌ها `400` جواب می‌گیرند با دلیل، و برخلاف معمول **همراه با هشدارها**:

```json
{
  "status": "error",
  "message": "device: registration carries no usable token",
  "warnings": [
    { "code": "transport_not_supported", "message": "...", "field": "apns" }
  ]
}
```

بدون آن‌ها، توسعه‌دهنده SDKای که از بیلد اندروید توکن APNs می‌فرستد فقط «توکن قابل استفاده‌ای نیست» را می‌بیند و هیچ سرنخی ندارد. کدهای هشدار اینجا `empty_token` و `token_too_long` (بیش از ۴۰۹۶) و `transport_not_supported` و `fcm_without_gms` هستند. دلایل رد، عینا، این‌ها هستند: `device: device_id is required` و `device: platform must be one of android, ios, web, windows, macos, linux` و `device: user_id or anonymous_id is required` و `device: registration carries no usable token`. ثبتی که `push_enabled: false` دارد و توکن ندارد پذیرفته می‌شود، چون آن یک تغییر وضعیت واقعی است.

شکست ذخیره‌سازی `503` است با `{"status":"error","message":"temporarily unavailable, please retry"}`. برخلاف رویداد، ثبت ناموفق هیچ بافری پشتش ندارد، پس SDK باید دوباره تلاش کند.

## POST /v1/devices/unregister {#devices-unregister}

```bash
curl -X POST https://in.segmentic.net/v1/devices/unregister \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"device_id":"d_5f2a91c4","user_id":"u_123","revoked":false}'
```

```json
{"status":"ok"}
```

`device_id` لازم است. بدون آن جواب `400 {"status":"error","message":"device_id is required"}` است، و بدنه‌ای که JSON معتبر نباشد هم دقیقا همین جواب را می‌گیرد نه `malformed JSON` را.

`revoked: true` یعنی اپ حذف نصب شده و آن نصب رفته است. `revoked: false`، که پیش‌فرض است، یعنی خروج از حساب: کاربر جدا می‌شود و توکن می‌ماند. این را روی خروج از حساب صدا بزنید. روی یک گوشی مشترک، ماندن حساب قبلی یعنی نفر بعدی به‌روزرسانی سفارش یک نفر دیگر را می‌گیرد.

## وب‌پوش {#webpush}

فقط وقتی سرو می‌شود که وب‌پوش پیکربندی شده باشد. دو مسیر.

`POST /v1/webpush/subscribe` همان چیزی را می‌گیرد که مرورگر به شما داده، چه تودرتو چه تخت:

```bash
curl -X POST https://in.segmentic.net/v1/webpush/subscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "subscription": {
      "endpoint": "https://fcm.googleapis.com/fcm/send/dK9...",
      "p256dh": "BJ7s...",
      "auth": "k1Qb..."
    }
  }'
```

```json
{"status":"ok"}
```

شکل تخت هم کار می‌کند:

```json
{"user_id":"u_123","endpoint":"https://...","p256dh":"BJ7s...","auth":"k1Qb..."}
```

شکل تودرتو برای این هست که صفحه بتواند هرچه مرورگر داده را بدون باز کردنش بفرستد، و مهم‌تر، بدون کدگذاری دوباره کلیدها. رشته `base64` که یک کمک‌کننده خوش‌نیت آن را دیکود و دوباره کد کرده باشد، کلاسیک‌ترین راهی است که یک اشتراک بی‌صدا از رمزگشایی می‌افتد.

هر چهار فیلد `user_id` و `endpoint` و `p256dh` و `auth` لازم‌اند. نبودن هرکدام `400 {"status":"error","message":"user_id and a complete subscription are required"}` است، چون endpoint بدون کلید بی‌فایده است: payload را نمی‌شود رمز کرد.

`POST /v1/webpush/unsubscribe` فقط `endpoint` می‌خواهد و هیچ شناسه کاربری بررسی نمی‌شود:

```bash
curl -X POST https://in.segmentic.net/v1/webpush/unsubscribe \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"endpoint":"https://fcm.googleapis.com/fcm/send/dK9..."}'
```

```json
{"status":"ok"}
```

خود endpoint راز آن اشتراک است. داشتنش همین حالا برای فرستادن به آن مرورگر کافی است، پس خواستن چیز بیشتری برای اینکه کسی بتواند دریافت را قطع کند، محافظت از جهت اشتباه است. `endpoint` خالی `400 {"status":"error","message":"endpoint is required"}` است.

شکست ذخیره‌سازی روی هر دو مسیر `503` است نه یک `200` بلعیده‌شده، چون کاربر همین حالا اجازه‌ای داده که صفحه نمی‌تواند دو بار بخواهدش.

> [!danger]
> اشتراک وب‌پوش به‌تنهایی هیچ‌کس را در دسترس نمی‌کند. پیش از رسیدن به فرستنده وب‌پوش، هر ارسال روی کانال `webpush` ردیف‌های دستگاه همان کاربر را می‌خواند و اگر ردیفی نباشد پیام را با دلیل `not_reachable` سرکوب می‌کند. این بررسی چه رجیستری دستگاه پیکربندی شده باشد چه نشده باشد اجرا می‌شود، پس روی نصبی که انبار دستگاه ندارد **هر** وب‌پوشی سرکوب می‌شود، و کمپین همان سرکوب را گزارش می‌دهد نه خطا را. اگر فقط پوش مرورگر را یکپارچه می‌کنید، همان کاربر را علاوه بر اشتراک با `POST /v1/devices` هم ثبت کنید (`platform: "web"`)، و قبل از اینکه کمپینی روی آن بسازید با یک ارسال آزمایشی مطمئن شوید.

## پیام‌رسان‌ها {#messenger}

فقط وقتی سرو می‌شود که پیام‌رسان‌ها پیکربندی شده باشند. چت بله، ایتا یا روبیکا را به یک پرونده وصل می‌کند.

```bash
curl -X POST https://in.segmentic.net/v1/messenger/link \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "platform": "bale",
    "chat_id": "44120099",
    "username": "ali_gh",
    "source": "bot_start"
  }'
```

```json
{"status":"ok"}
```

`platform` باید دقیقا `bale` یا `eitaa` یا `rubika` باشد. هرچیز دیگر، از جمله `telegram`، جواب `400 {"status":"error","message":"user_id, chat_id and a known platform are required"}` می‌گیرد. ستون پشت آن در دیتابیس یک قید بررسی دارد، پس مقدار ناشناخته وگرنه پایین‌تر با خطایی شکست می‌خورد که هیچ‌کس نمی‌تواند رویش کاری بکند.

`user_id` و `chat_id` و یک `platform` معتبر لازم‌اند. `username` و `source` اختیاری‌اند و همان‌طور که آمده‌اند رد می‌شوند. مقداری که رضایت واقعی را حمل می‌کند `bot_start` است، یعنی خود آدم بات را شروع کرده؛ هرچیز دیگری ارزش این را دارد که بعدا بشود پیدایش کرد.

`POST /v1/messenger/unlink` فقط `user_id` و `platform` می‌خواهد:

```bash
curl -X POST https://in.segmentic.net/v1/messenger/unlink \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"user_id":"u_123","platform":"bale"}'
```

```json
{"status":"ok"}
```

## صندوق درون‌برنامه‌ای {#inbox}

فقط وقتی سرو می‌شود که صندوق پیکربندی شده باشد. این تنها مسیر روی این میزبان است که داده شخصی یک آدم را می‌خواند، و کلید نوشتن عمومی نمی‌تواند چیزی باشد که از آن محافظت می‌کند.

هر درخواست یک اعتبارنامه دوم دارد، `user_hash`، که بک‌اند خودتان هنگام ورود کاربر حساب می‌کند:

```text
user_hash = lowercase_hex( HMAC-SHA256( identity_secret, user_id ) )
```

```bash
printf '%s' "u_123" \
  | openssl dgst -sha256 -hmac "$SEGMENTIC_IDENTITY_SECRET" -r \
  | cut -d' ' -f1
```

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

```bash
curl -X POST https://in.segmentic.net/v1/inbox \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "user_hash": "9f1c0b7d3e5a...",
    "limit": 20
  }'
```

```json
{
  "status": "ok",
  "messages": [
    {
      "message_id": "c104.u_123",
      "title": "سفارش شما ارسال شد",
      "body": "بسته شما تحویل پست شد.",
      "image": "https://cdn.example.ir/parcel.png",
      "deeplink": "myapp://orders/8821",
      "surface": "inbox",
      "token": "1.7.k2.ce.mfz1t8.9c4a...",
      "created_at": "2026-08-07T09:00:00Z",
      "expires_at": "2026-08-21T09:00:00Z",
      "seen": false
    }
  ]
}
```

`messages` همیشه آرایه است و هرگز `null` نیست، تا SDKای که بدون بررسی nil رویش حلقه می‌زند یک حلقه خالی بگیرد نه یک کرش.

`token` امضای انتساب همان پیام است. آن را در `context.campaign.token` روی رویداد `message_opened` که به `/v1/track` می‌فرستید برگردانید، تا بشود ثابت کرد آن باز شدن مال پیامی است که واقعا ما فرستاده‌ایم.

`POST` است نه `GET` و دو دلیل دارد: اثبات هویت جایش در بدنه است نه در کوئری‌استرینگی که هر پراکسی و هر تاریخچه مرورگر و هر لاگ دسترسی در مسیر یک کپی از آن نگه می‌دارد، و خود خواندن اثر جانبی دارد، چون ردیف‌ها با علامت «تحویل شد» برمی‌گردند.

`limit` بدون تغییر به انبار داده می‌رود. صفر یعنی سقف خود انبار اعمال می‌شود، و آن سقف اینجا منتشر نشده است.

هر شکست هویتی همان `403` است:

```json
{"status":"error","message":"user identity is not verified"}
```

هش غلط، هش نیامده و حسابی که اصلا راز هویت ندارد از هم قابل تشخیص نیستند، چون تشخیص‌دادنشان این نقطه را به ابزاری برای فهمیدن اینکه چه شناسه‌های کاربری وجود دارند تبدیل می‌کند. حالت «تاییدنشده» وجود ندارد.

`POST /v1/inbox/ack` همان اعتبارنامه را می‌گیرد به‌علاوه دو آرایه:

```bash
curl -X POST https://in.segmentic.net/v1/inbox/ack \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_123",
    "user_hash": "9f1c0b7d3e5a...",
    "seen": ["c104.u_123"],
    "dismissed": ["c99.u_123"]
  }'
```

```json
{"status":"ok"}
```

این ack هیچ تعاملی ثبت نمی‌کند. باز شدن درون‌برنامه‌ای از مسیر عادی رویداد گزارش می‌شود، با همان توکنی که صندوق به شما داده، تا دقیقا با همان بررسی امضایی تایید شود که هر کانال دیگری با آن تایید می‌شود. مسیر دوم و مورد اعتماد برای همان سیگنال، یک چیز دیگر برای خراب شدن بود، و این یکی به حرف فراخوانی اعتماد می‌کرد که چیزی جز یک کلید نوشتن عمومی ندارد.

## پیام‌های روی سایت {#onsite}

فقط وقتی سرو می‌شود که این قابلیت پیکربندی شده باشد. سه مسیر: چه چیزی نشان بده، و چه اتفاقی افتاد.

`GET /v1/onsite` تنها درخواستی در محصول است که روی مسیر بحرانی رندر سایت یک نفر دیگر می‌دود، و هر تصمیمی درباره‌اش از همین درمی‌آید. هیچ هویت کاربری ندارد، پس یک پاسخ به همه بازدیدکننده‌ها خدمت می‌کند و CDN می‌تواند کشش کند. به‌جای تصمیم، قاعده هدف‌گیری برمی‌گرداند، پس مرورگر بدون رفت‌وبرگشت همان‌جا تطبیق می‌دهد.

```bash
curl "https://in.segmentic.net/v1/onsite?write_key=wk_seg_..."
```

```http
HTTP/1.1 200 OK
Cache-Control: public, max-age=60
Content-Type: application/json; charset=utf-8
```

```json
{
  "campaigns": [
    {
      "id": 12,
      "name": "بنر تخفیف نوروز",
      "kind": "banner",
      "status": "live",
      "content": { },
      "targeting": { },
      "max_impressions": 3,
      "cooldown_hours": 24,
      "dismissible": true,
      "starts_at": "2026-03-15T00:00:00Z",
      "ends_at": "2026-03-25T00:00:00Z",
      "impressions": 41822,
      "clicks": 1104,
      "dismissals": 380
    }
  ],
  "cache_seconds": 60
}
```

`kind` یکی از `banner` و `modal` و `slidein` و `survey` است. `content` و `targeting` در نمونه بالا جمع شده‌اند؛ شکلشان در [پیام‌های روی سایت](/docs/onsite) هست. شصت ثانیه آن‌قدر بلند هست که بیشتر بازدیدهای یک فروشگاه شلوغ اصلا به ما نرسد، و آن‌قدر کوتاه هست که مکث‌کردن یک کمپین وقتی هنوز همان آدمی که دکمه را زده نگاه می‌کند اثر بگذارد. به‌خاطر همین پنجره، مرورگر تاریخ شروع و پایان را دوباره محلی چک می‌کند، تا کمپینی که پایانش وسط کش رد شود بدون منتظرماندن متوقف شود.

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

شکست ذخیره‌سازی اینجا `200` با فهرست خالی جواب می‌دهد، هرگز `5xx`. این داخل بارگذاری صفحه شما می‌دود: خرابی ما باید به «امروز بنری نیست» تنزل کند، نه به یک خطای کنسول روی سایت شما.

`POST /v1/onsite/event` ثبت می‌کند چه اتفاقی افتاد:

```bash
curl -X POST https://in.segmentic.net/v1/onsite/event \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 12,
    "anonymous_id": "a_9f21c0",
    "action": "click",
    "page_url": "https://shop.example.ir/p/118820"
  }'
```

```json
{"status":"ok"}
```

`campaign_id` و یکی از `user_id` یا `anonymous_id` لازم‌اند؛ بدونشان جواب `400 {"status":"error","message":"campaign_id and a visitor id are required"}` است. `action` یکی از `impression` (رشته خالی هم همین معنی را دارد) و `click` و `dismiss` و `convert` است و به حروف کوچک و بزرگ حساس نیست؛ هرچیز دیگر `400 {"status":"error","message":"unknown action"}` است.

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

`POST /v1/onsite/response` جواب یک نظرسنجی را ثبت می‌کند و `score` و `answers` را هم می‌گیرد:

```bash
curl -X POST https://in.segmentic.net/v1/onsite/response \
  -H "Authorization: Bearer wk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "campaign_id": 31,
    "user_id": "u_123",
    "score": 9,
    "answers": { "why": "ارسال سریع بود" },
    "page_url": "https://shop.example.ir/thanks"
  }'
```

```json
{"status":"ok"}
```

کمپین به‌جای اینکه به آن اعتماد شود، خوانده و بررسی می‌شود: اینکه این یک نظرسنجی NPS هست یا نه تعیین می‌کند که امتیاز اصلا معنایی دارد یا نه، و مرورگر مرجع این موضوع نیست. شناسه‌ای که پیدا نشود `400 {"status":"error","message":"unknown campaign"}` است. کمپینی که نظرسنجی نیست `400 {"status":"error","message":"onsite: this campaign is not a survey"}` است. وقتی کمپین NPS تنظیم شده باشد امتیاز باید آمده باشد و بین ۰ تا ۱۰ باشد وگرنه `onsite: an NPS score must be between 0 and 10`؛ وقتی NPS نباشد امتیاز به `-1` مجبور می‌شود، یعنی «بدون امتیاز». **نیامدن این فیلد رد می‌شود، نه اینکه صفر خوانده شود.** قبلا صفر خوانده می‌شد و صفر یک نمره منتقد معتبر است، پس بدنه‌ای که اصلا امتیاز نداشت به‌عنوان بدترین جواب مقیاس ذخیره می‌شد و امتیازی را که همان نفر قبلا داده بود جایگزین می‌کرد. پاسخ متنی بلندتر از ۲۰۰۰ کاراکتر به‌جای رد شدن بریده می‌شود، چون کسی که سه پاراگراف درباره تحویل سفارشش نوشته حرفی زده که ارزش نگه‌داشتن دارد. ثبت یک پاسخ یک `convert` هم ثبت می‌کند، تا کسی که نظرش را گفته هفته بعد همان سوال را نگیرد.

شکست ذخیره‌سازی اینجا `503` است، برخلاف دو مسیر دیگر روی سایت: یک پاسخ، شمارنده نیست.

## GET /v1/status {#status}

بدون احراز هویت، بدون خواندن دیتابیس، بدون محدودیت نرخ.

```bash
curl -i https://in.segmentic.net/v1/status
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z

{"status":"ok","service":"collector","version":"1.42.0"}
```

`version` مهر بیلد کالکتوری است که دارد می‌دود، تا «آیا استقرار رفته بالا» از بیرون قابل جواب‌دادن باشد. `/readyz` و نقطه متریک‌ها روی یک listener مدیریتی جداگانه‌اند و جزو این سطح نیستند.

## مسیرهایی که فقط با پیکربندی وجود دارند {#conditional-routes}

قابلیتی که جایی برای نوشتن ندارد اصلا سرو نمی‌شود، به‌جای اینکه داده‌ای را بپذیرد که بی‌صدا دور می‌ریزد. `404` همان لحظه به توسعه‌دهنده SDK می‌گوید که یک قابلیت پیکربندی نشده؛ `200`ای که بی‌صدا اشتراک را دور انداخته باشد هفته‌ها بعد پیدا می‌شود، توسط کمپینی که به هیچ‌کس نرسیده.

| مسیرها | کی سرو می‌شود |
|---|---|
| `/v1/track`، `/v1/identify`، `/v1/page`، `/v1/screen`، `/v1/alias`، `/v1/batch`، `/v1/status` | همیشه |
| `/v1/devices`، `/v1/devices/unregister` | وقتی انبار دستگاه پیکربندی شده باشد |
| `/v1/webpush/subscribe`، `/v1/webpush/unsubscribe` | وقتی وب‌پوش پیکربندی شده باشد |
| `/v1/messenger/link`، `/v1/messenger/unlink` | وقتی پیام‌رسان‌ها پیکربندی شده باشند |
| `/v1/inbox`، `/v1/inbox/ack` | وقتی صندوق پیکربندی شده باشد |
| `/v1/onsite`، `/v1/onsite/event`، `/v1/onsite/response` | وقتی پیام‌های روی سایت پیکربندی شده باشند |

مسیر ثبت‌نشده زیر `/v1/` جواب **`405 Method Not Allowed` می‌دهد نه `404`**. الگوی preflight مربوط به CORS هر مسیری زیر آن پیشوند را برای `OPTIONS` برداشته، پس روتر مسیر را می‌شناسد و متد را نه. `405` اینجا را «این قابلیت روی این استقرار روشن نیست» بخوانید و با همان چشمی نگاهش کنید که به `404` نگاه می‌کنید. بیرون از `/v1/`، مسیر ثبت‌نشده یک `404` ساده است.

## بقیه مسیرهای این میزبان {#other-paths}

این‌ها روی همین میزبان‌اند و جزو سطح SDK نیستند. هرکدام جای خودشان توضیح داده شده‌اند.

| مسیر | چیست |
|---|---|
| `GET /e/o` | پیکسل باز شدن ایمیل. همیشه یک GIF شفاف جواب می‌دهد، حتی برای توکن جعلی، چون تصویر شکسته وسط یک ایمیل تبلیغاتی آشکارترین نقصی است که گیرنده می‌بیند |
| `GET /e/u` و `POST /e/u` | لغو اشتراک یک‌کلیکی. آن `GET` عمدا لغو اشتراک نمی‌کند؛ [رضایت و لغو اشتراک](/docs/consent) را ببینید |
| `GET /e/p` و `POST /e/p` | مرکز ترجیحات گیرنده |
| `POST /v1/hooks/{source}/{token}` | وبهوک پلتفرم‌ها از دیجی‌کالا، باسلام، ترب، زرین‌پال، ووکامرس، شاپیفای و سگمنت؛ [وبهوک‌ها](/docs/webhooks) را ببینید |
| `POST /v1/bounce/{local}` | ورودی برگشت ایمیل، که با نشانی بازگشت خودش آدرس‌دهی می‌شود |
| `GET /sdk/*` | باندل SDK مرورگر، که از همین origin سرو می‌شود تا یک ورودی در سیاست امنیت محتوای شما هم اسکریپت و هم درخواست‌هایش را پوشش بدهد |
| `GET /s/*` | اینجا یک `404` قطعی. لینک‌های کوتاه روی دامنه کوتاه خودشان هستند، چون دامنه کوتاه‌تر یعنی کاراکتر کمتر در هر پیامک |

هیچ‌کدام از این‌ها کلید نوشتن نمی‌گیرند. نقاط ایمیلی به‌جایش یک توکن امضاشده می‌گیرند، که قوی‌تر است: کلید نوشتن عمدا عمومی است و امضا نیست.

## چیزهایی که این میزبان ندارد {#absent}

- **هیچ نوع محدودیت نرخی نیست.** نه در ثانیه، نه به‌ازای کلید، نه به‌ازای IP، نه روی لبه و نه داخل برنامه. تنها کنترل حجم سهمیه ماهانه است که `402` جواب می‌دهد.
- **`503` مربوط به شکست انتشار هدر `Retry-After` ندارد.** فقط `503` جست‌وجوی کلید دارد. از عقب‌نشینی خودتان استفاده کنید.
- **`Content-Type` اعمال نمی‌شود.** بدنه JSON با هر برچسبی پذیرفته می‌شود.
- **مذاکره زبان نیست.** `Accept-Language` نادیده گرفته می‌شود؛ تنها پیام آدم‌خوان روی این میزبان، یعنی رد شدن به دلیل سهمیه، همیشه فارسی است.
- **مکان‌یابی جغرافیایی از روی IP نیست.** مکان فقط از `context.location` می‌آید.
- **نسخه `GET` و `PUT` و `DELETE` نقاط رویداد وجود ندارد.** `GET /v1/track` جواب `405` می‌دهد.
- **روی مسیر بسته ضبط دیباگ نیست**، پس دیباگر زنده رویداد در پنل نسبت به ترافیک بسته‌ای کور است.
- **کوکی سمت سرور و شناسه ناشناس ساخته‌شده توسط سرور وجود ندارد.** ماندگارکردن `anonymous_id` کاملا کار SDK است، و اگر کلاینت خودتان را می‌نویسید، کار شماست.
- **هیچ راهی برای خواندن دوباره یک رویداد نیست.** هیچ‌چیز روی این میزبان آنچه فرستاده‌اید را برنمی‌گرداند. آن را در پنل یا از [API مدیریتی](/docs/api/management) کوئری کنید.

---

# مرجع: API مدیریتی

> مسیرهای پایدار میزبان مدیریتی برای مخاطب، کمپین، سناریو، گزارش و پیام، همراه مجوز هرکدام.
> https://segmentic.net/docs/api/management

## میزبان و کلید {#surface}

این API روی `https://api.segmentic.net` جواب می‌دهد و همه مسیرهایش با `/v1/` شروع می‌شود. هیچ بخش `/api` در مسیر نیست. اگر جایی نوشته شده `/api/v1/events`، غلط است و جواب آن `404` با کد `unknown_endpoint` است.

کلیدی که اینجا پذیرفته می‌شود فقط `sk_seg_...` است. کلید نوشتن (`wk_seg_...`) که داخل اپ و سایت شما می‌نشیند، اینجا رد می‌شود و پیام خطا هم دقیقا همین را می‌گوید، چون کسی که با کلید نوشتن به اینجا رسیده معمولا مقدار اشتباه را از یک صفحه کپی کرده و کلمه «unauthorized» او را دنبال یک غلط املایی می‌فرستد.

این یک شنونده جداگانه است، نه یک پیشوند مسیر. دو مسیری که کلید می‌سازند (`POST /v1/team/keys` و `POST /v1/team/invites`) متن خام یک اعتبارنامه را در بدنه پاسخ برمی‌گردانند؛ روی یک mux مشترک، فاصله آن‌ها تا عمومی‌شدن یک نگهبان فراموش‌شده بود. اینجا اصلا آدرس‌پذیر نیستند. نبودن از بررسی‌کردن بهتر است.

سه چیز که مرورگر را از این در بیرون نگه می‌دارد:

- نشست (کوکی) پذیرفته نمی‌شود. یعنی این سطح هیچ اعتبارنامه محیطی ندارد و پرسش CSRF از اساس منتفی است.
- هیچ پاسخ‌دهنده‌ای برای preflight روی این mux ثبت نشده است. یک `OPTIONS` از مرورگر به catch-all می‌خورد و `404` می‌گیرد.
- این API برای تماس سرور به سرور است. کلید `sk_seg_` را در کد سمت مرورگر نگذارید.

روی یک نصب محلی، این API تا وقتی `PUBLIC_API_ADDR` مقدار نگیرد اصلا سرو نمی‌شود؛ پیش‌فرضش رشته خالی است. اولین کاری که یک یکپارچه‌سازی تازه باید بکند خواندن `GET /v1/status` است.

متد اشتباه روی یک مسیر واقعی، `405` نمی‌گیرد. چون مسیرها با الگوی متددار ثبت شده‌اند، `PUT /v1/campaigns/5` به catch-all می‌افتد و همان `404` با کد `unknown_endpoint` را می‌گیرد.

### همه مسیرها، یک‌جا {#routes}

بیست‌ودو مسیر با احراز هویت، به‌علاوه یک probe وضعیت و یک catch-all. غیر از این‌ها چیزی روی این میزبان وجود ندارد.

| متد و مسیر | مجوز | هزینه | قفل نرم | وقتی ثبت می‌شود که |
|---|---|---|---|---|
| `GET /v1/status` | ندارد | ندارد | خیر | همیشه |
| `GET /v1/whoami` | ندارد | ۱ | خیر | همیشه |
| `GET /v1/capabilities` | ندارد | ۱ | خیر | همیشه |
| `GET /v1/schema/events` | `event.read` | ۵ | خیر | همیشه |
| `GET /v1/schema/traits` | `event.read` | ۵ | خیر | همیشه |
| `GET /v1/ingest/quality` | `event.read` | ۵ | خیر | همیشه |
| `POST /v1/audiences/validate` | `segment.read` | ۱ | خیر | همیشه |
| `POST /v1/audiences/count` | `segment.read` | ۲۵ | خیر | همیشه |
| `GET /v1/segments` | `segment.read` | ۱ | خیر | `segments` |
| `GET /v1/segments/{id}` | `segment.read` | ۱ | خیر | `segments` |
| `POST /v1/segments` | `segment.write` | ۱ | خیر | `segments` |
| `PUT /v1/segments/{id}` | `segment.write` | ۱ | خیر | `segments` |
| `DELETE /v1/segments/{id}` | `segment.delete` | ۱ | خیر | `segments` |
| `GET /v1/campaigns` | `campaign.read` | ۱ | خیر | `campaigns` |
| `GET /v1/campaigns/{id}` | `campaign.read` | ۵ | خیر | `campaigns` |
| `POST /v1/campaigns` | `campaign.write` | ۱ | خیر | `campaigns` |
| `PUT /v1/campaigns/{id}/recurrence` | `campaign.send` | ۱ | خیر | `campaigns` و `campaign_recurrence` |
| `DELETE /v1/campaigns/{id}/recurrence` | `campaign.write` | ۱ | خیر | `campaigns` و `campaign_recurrence` |
| `POST /v1/campaigns/{id}/send` | `campaign.send` | ۱ | خیر | `campaigns` |
| `GET /v1/templates` | `template.read` | ۱ | خیر | `templates` |
| `GET /v1/templates/{id}` | `template.read` | ۱ | خیر | `templates` |
| `POST /v1/templates` | `template.write` | ۱ | خیر | `templates` |
| `POST /v1/templates/render` | `template.read` | ۱ | خیر | `templates` |
| `GET /v1/journeys` | `journey.read` | ۱ | خیر | `journeys` |
| `GET /v1/journeys/{id}` | `journey.read` | ۵ | خیر | `journeys` |
| `POST /v1/journeys` | `journey.write` | ۱ | خیر | `journeys` |
| `GET /v1/journeys/{id}/draft` | `journey.read` | ۱ | خیر | `journeys` |
| `POST /v1/journeys/validate` | `journey.read` | ۱ | خیر | `journeys` |
| `POST /v1/journeys/{id}/publish` | `journey.publish` | ۱ | خیر | `journeys` |
| `POST /v1/journeys/{id}/{action}` | `journey.write` | ۱ | خیر | `journeys` |
| `POST /v1/campaigns/{id}/submit` | `campaign.write` | ۱ | خیر | `campaigns` و `campaign_approval` |
| `POST /v1/events` | `profile.write` | ۵ | خیر | `ingest` |
| `GET /v1/exports` | `data.export` | ۱ | بله | `async_exports` |
| `POST /v1/exports` | `data.export` | ۲۵ | بله | `async_exports` |
| `POST /v1/reports/funnel` | `analytics.read` | ۲۵ | بله | `analytics` |
| `POST /v1/reports/retention` | `analytics.read` | ۲۵ | بله | `analytics` |
| `POST /v1/messages` | `campaign.send` | ۱ | خیر | `transactional` |

ستون آخر نام یک کلید در `features` پاسخ [توانمندی‌ها](/docs/api/management#capabilities) است. اگر آن کلید `false` باشد، آن مسیرها روی این نصب اصلا ثبت نشده‌اند و `404` می‌دهند. ستون هزینه در [بودجه درخواست](/docs/api/management#budget) و ستون قفل در [قفل نرم](/docs/api/management#lock) توضیح داده شده است.

---

## احراز هویت {#auth}

اعتبارنامه از سه جا خوانده می‌شود، به همین ترتیب:

1. هدر `Authorization: Bearer <token>` (نام scheme به بزرگی و کوچکی حرف حساس نیست)
2. هدر `X-Segmentic-Key: <token>`
3. کوکی‌های `__Host-segmentic_session` و بعد `segmentic_session`

کوکی عمدا آخر است: درخواستی که هدر `Authorization` صریح دارد، قصد کرده از همان استفاده کند، و ترجیح‌دادن بی‌صدای یک کوکی محیطی همان‌جایی است که یک مرورگر، درخواست یک کلاینت API را با هویت اشتباه انجام می‌دهد. روی این سطح کوکی به‌هرحال بی‌فایده است، چون نشست پذیرفته نمی‌شود.

```bash
curl -s https://api.segmentic.net/v1/whoami \
  -H "Authorization: Bearer sk_seg_..."
```

چهار جواب `401` وجود دارد و هرکدام کد خودش را دارد، چون هرکدام شما را به یک جای متفاوت می‌فرستند:

| کد | چه اتفاقی افتاده | پیام |
|---|---|---|
| `unauthenticated` | هیچ اعتبارنامه‌ای نیامده، یا آمده و resolve نشده (کلید باطل‌شده، کلید ناشناس، حساب معلق) | `a valid API key is required` |
| `api_key_required` | یک نشست معتبر آمده، کوکی یا bearer | `this API accepts sk_seg_ keys only; session credentials are not valid here` |
| `write_key_rejected` | توکنی که با `wk_` شروع می‌شود، پیش از هر جست‌وجویی در دیتابیس | متن پایین |
| `key_expired` | ستون `expires_at` کلید گذشته است | `this API key has expired` |

```json
{
  "error": {
    "code": "write_key_rejected",
    "message": "that is an SDK write key (wk_…); this API needs a management key (sk_seg_…)"
  }
}
```

> [!warn]
> کد `key_revoked` وجود ندارد. کلید باطل‌شده و حساب معلق هر دو `unauthenticated` می‌گیرند، یعنی از «کلید را اشتباه تایپ کردی» قابل تشخیص نیستند. اگر کلیدی که دیروز کار می‌کرد امروز `unauthenticated` می‌دهد، اول در پنل ببینید باطل شده یا نه.

نبود مجوز، `403` است و نام مجوز را می‌گوید. این عمدی است: جایگزینش این است که مشتری برای فهمیدن اینکه کدام مجوز را باید بدهد، تیکت پشتیبانی باز کند.

```json
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry campaign.send, see GET /v1/whoami for what it does carry",
    "need": "campaign.send"
  }
}
```

دو چیز دیگر هم می‌توانند جلوی یک کلید معتبر را بگیرند و هیچ‌کدام در پاکت این سطح نیستند:

- اگر حساب فهرست IP مجاز تنظیم کرده باشد و آن را به کلیدهای API هم اعمال کرده باشد، تماس از نشانی خارج از فهرست `403` با کد `ip_not_allowed` می‌گیرد.
- اعتبارنامه کارکنان سگمنتیک روی این میزبان `401` با کد `wrong_surface` می‌گیرد.

هر دو در پاکت مسطح پنل جواب می‌دهند، نه پاکت این API. جزئیات در [پاکت خطا](/docs/api/management#errors).

کلیدها منقضی می‌شوند. کلیدی که در پنل ساخته می‌شود، اگر عددی ندهید، ۳۶۵ روز عمر دارد. کلید با نقش `owner` ساخته نمی‌شود، پس هیچ کلید API هرگز `tenant.transfer` یا `tenant.delete` ندارد.

---

## GET /v1/whoami {#whoami}

مجوز نمی‌خواهد، هزینه ۱. هر کلید معتبری جواب می‌گیرد. این و [توانمندی‌ها](/docs/api/management#capabilities) دو تماسی هستند که یک کلاینت باید موقع راه‌اندازی بزند: یکی می‌گوید این کلید چه می‌تواند، دیگری می‌گوید این نصب چه دارد.

```bash
curl -s https://api.segmentic.net/v1/whoami \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "tenant_id": 7,
  "api_key_id": 3,
  "role": "analyst",
  "permissions": [
    "analytics.read",
    "audit.read",
    "campaign.read",
    "data.export",
    "event.read",
    "journey.read",
    "member.read",
    "profile.read",
    "segment.read",
    "settings.read",
    "template.read"
  ],
  "scoped": false
}
```

| فیلد | نوع | همیشه هست | معنی |
|---|---|---|---|
| `tenant_id` | عدد | بله | شناسه حساب |
| `api_key_id` | عدد | بله | شناسه همین کلید. نام روی سیم `api_key_id` است، نه `key_id` |
| `role` | رشته | بله | یکی از هفت نقش |
| `permissions` | آرایه رشته | بله، خالی هم `[]` است نه `null` | مجموعه موثر، مرتب‌شده الفبایی |
| `scoped` | بولین | بله | اینکه کلید زیر نقش خودش باریک شده یا نه |

`permissions` مجموعه موثر است: گرنت‌های نقش، تقاطع با scope کلید. یک کلاینت خوب‌نوشته‌شده می‌تواند سر راه‌اندازی شکست بخورد به‌جای اینکه ماهی یک‌بار روی همان تماسی که مجوزش را ندارد شکست بخورد.

اینجا عمدا ایمیل، نام و فهرست حساب‌ها نیست. کلیدی که خودش را معرفی می‌کند لازم نیست بداند کدام آدم آن را ساخته، و منتشرکردنش هر کلید را به یک افشای کوچک هویت تبدیل می‌کند.

> [!note]
> بودجه باقی‌مانده در این پاسخ نیست. هیچ راهی برای پرسیدن «چقدر بودجه مانده» وجود ندارد. رجوع کنید به [بودجه درخواست](/docs/api/management#budget).

`scoped` عملا همیشه `false` است. ستون `scopes` روی کلیدها در دیتابیس خوانده می‌شود ولی هیچ کد Go چیزی در آن نمی‌نویسد، و هیچ مسیر API یا صفحه پنلی برای تنظیمش وجود ندارد. کلید باریک‌شده امروز فقط با نوشتن مستقیم در دیتابیس ساخته می‌شود.

---

## GET /v1/capabilities {#capabilities}

مجوز نمی‌خواهد، هزینه ۱. این پاسخ می‌گوید این نصب امروز چه چیزی را سرو می‌کند و چه سقف‌هایی را اعمال می‌کند.

```bash
curl -s https://api.segmentic.net/v1/capabilities \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "version": "v1",
  "features": {
    "segments": true,
    "campaigns": true,
    "analytics": true,
    "transactional": true,
    "export": false,
    "import": true,
    "journeys": true,
    "ingest": true,
    "async_exports": true,
    "campaign_approval": true
  },
  "limits": {
    "max_page_size": 100,
    "max_preview_rows": 100,
    "max_batch_size": 500,
    "estimate_sample": 100,
    "query_timeout_sec": 30
  }
}
```

دو نصب سگمنتیک واقعا با هم فرق دارند: بیشتر قابلیت‌ها فقط وقتی ثبت می‌شوند که پیکربندی‌شان وجود داشته باشد، پس کلاینتی که کل سطح را فرض کند دارد علیه یک داستان کد می‌نویسد. سقف‌ها هم منتشر می‌شوند به‌جای اینکه فقط مستند شوند، تا هیچ کلاینت و هیچ عاملی عددی را hardcode نکند که ما بعدا عوضش می‌کنیم.

### features، کلید به کلید {#capabilities-features}

| کلید | چه مسیرهایی را روشن می‌کند |
|---|---|
| `segments` | پنج مسیر `/v1/segments` |
| `campaigns` | هفت مسیر `/v1/campaigns` |
| `campaign_recurrence` | دو مسیر `PUT` و `DELETE /v1/campaigns/{id}/recurrence` |
| `analytics` | `POST /v1/reports/funnel` و `POST /v1/reports/retention` |
| `transactional` | `POST /v1/messages` |
| `export` | **هیچ‌چیز روی این میزبان.** خروجی‌گیر CSV پنل است. اطلاعاتی است و بس |
| `import` | **هیچ‌چیز روی این میزبان.** بارگذاری CSV پنل است |
| `journeys` | **هیچ‌چیز روی این میزبان.** سناریو هیچ مسیر عمومی ندارد |
| `ingest` | `POST /v1/events` |
| `async_exports` | `GET /v1/exports` و `POST /v1/exports` |
| `campaign_approval` | `POST /v1/campaigns/{id}/submit`، و دروازه تایید روی ارسال |

`ingest` و `import` دقیقا یک بولین‌اند و هیچ‌وقت با هم مخالف نمی‌شوند. جدا نام‌گذاری شده‌اند چون کلاینتی که این دو را یکی بگیرد، ممکن است یک batch را به نصبی بفرستد که فقط آن یکی را سرو می‌کند.

### limits، کلید به کلید {#capabilities-limits}

| کلید | مقدار | چه چیزی را واقعا محدود می‌کند |
|---|---|---|
| `max_page_size` | ۱۰۰ | سقف `?limit=` روی `GET /v1/exports`. تنها مسیری که `limit` را می‌خواند |
| `max_preview_rows` | ۱۰۰ | پیش‌نمایش سگمنت در پنل. **هیچ مسیری روی این میزبان به آن پایبند نیست**، چون مسیر پیش‌نمایش عمومی وجود ندارد |
| `max_batch_size` | ۵۰۰ | حداکثر رویداد در یک `POST /v1/events` |
| `estimate_sample` | ۱۰۰ | نرخ نمونه‌گیری شمارنده زنده پنل. **هیچ مسیری روی این میزبان از آن استفاده نمی‌کند** |
| `query_timeout_sec` | ۳۰ | مهلت context روی بیشتر هندلرها |

`query_timeout_sec` مهلت دو مسیر گزارش نیست. قیف و ماندگاری ۴۵ ثانیه مهلت دارند و این عدد هیچ‌جا منتشر نمی‌شود.

---

## مجوزها {#permissions}

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

بیست‌وهشت مجوز وجود دارد. این‌ها رشته‌های دقیق روی سیم‌اند.

| مجوز | روی این میزبان چه چیزی را باز می‌کند |
|---|---|
| `segment.read` | `POST /v1/audiences/validate`، `POST /v1/audiences/count`، `GET /v1/segments`، `GET /v1/segments/{id}` |
| `segment.write` | `POST /v1/segments`، `PUT /v1/segments/{id}` |
| `segment.delete` | `DELETE /v1/segments/{id}` |
| `profile.read` | هیچ‌چیز |
| `profile.write` | `POST /v1/events` |
| `event.read` | `GET /v1/schema/events`، `GET /v1/schema/traits` |
| `campaign.read` | `GET /v1/campaigns`، `GET /v1/campaigns/{id}` |
| `campaign.write` | `POST /v1/campaigns`، `POST /v1/campaigns/{id}/submit`، `DELETE /v1/campaigns/{id}/recurrence` |
| `campaign.send` | `PUT /v1/campaigns/{id}/recurrence`، `POST /v1/campaigns/{id}/send`، `POST /v1/messages` |
| `campaign.approve` | هیچ‌چیز. مسیر تایید عمومی وجود ندارد |
| `analytics.read` | `POST /v1/reports/funnel`، `POST /v1/reports/retention` |
| `data.export` | `GET /v1/exports`، `POST /v1/exports` |
| `journey.read` | هیچ‌چیز |
| `journey.write` | هیچ‌چیز |
| `journey.publish` | هیچ‌چیز |
| `template.read` | هیچ‌چیز |
| `template.write` | هیچ‌چیز |
| `member.read` | هیچ‌چیز |
| `member.write` | هیچ‌چیز |
| `apikey.read` | هیچ‌چیز |
| `apikey.write` | هیچ‌چیز |
| `settings.read` | هیچ‌چیز |
| `settings.write` | هیچ‌چیز |
| `billing.read` | هیچ‌چیز |
| `billing.write` | هیچ‌چیز |
| `audit.read` | هیچ‌چیز |
| `tenant.transfer` | هیچ‌چیز، و هیچ کلیدی نمی‌تواند آن را داشته باشد |
| `tenant.delete` | هیچ‌چیز، و هیچ کلیدی نمی‌تواند آن را داشته باشد |

هجده تا از این بیست‌وهشت تا روی این میزبان هیچ دری باز نمی‌کنند. برای پنل و برای سطح داشبورد لازم‌اند. اگر کلیدتان یکی از آن‌ها را دارد، اینجا بی‌اثر است.

### نقش‌ها {#permissions-roles}

نقش‌ها به‌جای ارث‌بری، سطر به سطر نوشته شده‌اند. ارث‌بری («ادمین یعنی viewer به‌علاوه چند چیز») پرسش جالب یعنی «دقیقا یک marketer چه می‌تواند بکند» را به خواندن چهار تعریف دیگر و ترکیب‌کردنشان در ذهن تبدیل می‌کند.

| نقش | روی این میزبان چه می‌تواند بکند |
|---|---|
| `owner` | هر بیست‌ودو مسیر. ولی کلید API با این نقش ساخته نمی‌شود |
| `admin` | هر بیست‌ودو مسیر |
| `marketer` | هر بیست‌ودو مسیر. ده مجوزی که این میزبان استفاده می‌کند همه در این نقش هستند |
| `analyst` | فهرست رویدادها و ویژگی‌ها، اعتبارسنجی و شمارش مخاطب، خواندن سگمنت، خواندن کمپین، گزارش، خروجی |
| `viewer` | همان‌های analyst، منهای خروجی |
| `approver` | همان‌های viewer. `campaign.approve` را دارد که اینجا هیچ دری ندارد |
| `finance` | هیچ‌چیز. فقط `billing.read` و `billing.write` دارد و هیچ‌کدام اینجا دری ندارند |

اگر یک عامل هوش مصنوعی می‌سازید و می‌خواهید فقط گزارش بخواند، `viewer` بدهید نه `analyst`. `analyst` سه مجوز دارد که `viewer` ندارد: `profile.read` و `data.export` و `audit.read`، یعنی دیدن شماره تلفن آدم‌ها، بیرون‌بردن فایل، و خواندن رد ممیزی. روی این میزبان فقط `data.export` مسیری باز می‌کند. آن دو تای دیگر همان‌طور که جدول مجوزها در بالا می‌گوید اینجا هیچ‌چیز باز نمی‌کنند.

---

## بودجه درخواست {#budget}

«درخواست در دقیقه» واحد غلطی است برای سطحی که یک تماسش یک struct می‌خواند و تماس بعدی‌اش یک انبار داده را اسکن می‌کند. عاملی که در دقیقه ۶۰۰ بار `whoami` بزند بی‌آزار است؛ عاملی که در دقیقه ۶۰۰ گزارش ماندگاری بگیرد یک قطعی خودخواسته است.

پس بودجه وزنی است، نه شمارشی. سه کلاس هزینه وجود دارد:

| کلاس | واحد | یعنی |
|---|---|---|
| trivial | ۱ | چیزی نمی‌خواند، یا یک سطر با کلید اصلی می‌خواند |
| query | ۵ | یک کوئری کران‌دار روی انبار داده |
| heavy | ۲۵ | اسکنی که هزینه‌اش با تاریخچه حساب بزرگ می‌شود |

سهمیه پیش‌فرض **۶۰۰ واحد در دقیقه** است، با متغیر محیطی `PUBLIC_API_BUDGET_PER_MINUTE`. یعنی تقریبا «دو گزارش سنگین در دقیقه، یا ششصد تماس ارزان».

سهمیه **به‌ازای هر کلید** است، نه هر حساب. عمدی: مشتری یک کلید باریک به یک عامل می‌دهد و کلید خودش را جدا نگه می‌دارد، و یک عامل رهاشده نباید بتواند بودجه‌ای را که خط لوله سفارش‌ها به آن وابسته است تمام کند.

پنجره یک **دقیقه تقویمی ثابت** است، نه لغزان. کلید Redis برای هر ترکیب حساب و کلید و شماره دقیقه ساخته می‌شود و ۷۰ ثانیه عمر می‌کند.

وقتی بودجه تمام شود:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8

{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```

هزینه **پیش از** رد کردن کسر می‌شود، پس کلاینتی که به یک کلید تمام‌شده فشار می‌آورد فقط شمارنده همان دقیقه را باد می‌کند؛ پنجره را طولانی‌تر نمی‌کند ولی چیزی هم به دست نمی‌آورد. چون پنجره تقویمی است، `Retry-After: 60` محافظه‌کارانه است و بودجه ممکن است زودتر برگردد.

اگر خود شمارنده در دسترس نباشد، جواب `503` است:

```json
{"error":{"code":"budget_unavailable","message":"could not verify the request budget"}}
```

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

> [!warn]
> نتیجه‌اش این است: قطعی Redis کل این API را با `503` می‌خواباند، از جمله `GET /v1/whoami` و `GET /v1/capabilities`. تنها مسیری که زنده می‌ماند `GET /v1/status` است.

> [!note]
> هدر `X-RateLimit-Limit`، `X-RateLimit-Remaining` و `X-RateLimit-Reset` روی بودجه وجود ندارد. کلاینت نمی‌تواند ببیند چقدر مانده و `whoami` هم نمی‌گوید. تنها هدرهای نرخ روی این میزبان مال `POST /v1/messages` است و آن‌ها محدودکننده جداگانه پیام را توصیف می‌کنند، نه بودجه را.

`403` بابت نبود مجوز پیش از کسر بودجه می‌آید، پس تماس رد شده بابت مجوز هزینه‌ای ندارد.

---

## صفحه‌بندی {#pagination}

این بخش را کامل بخوانید، چون آنچه در کد هست با آنچه انتظار دارید فرق می‌کند.

پاکت صفحه این شکل است:

```json
{
  "data": [],
  "has_more": false
}
```

`next_cursor` روی `omitempty` است، پس هر وقت خالی باشد این کلید اصلا در بدنه نیست، و امروز روی هر پاسخی همین‌طور است. هیچ‌جای کد مقداری در آن نمی‌نویسد، پس شکلش چیزی نیست که بشود دید. اگر روزی آمد، مبهم فرضش کنید: کلاینتی که آن را parse کند کلاینتی است که دیگر هرگز نمی‌توانیم ترتیب را برایش عوض کنیم.

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

> [!danger]
> **امروز صفحه‌بندی کار نمی‌کند.** `?limit=` فقط روی `GET /v1/exports` خوانده می‌شود. `?cursor=` هیچ‌جا خوانده نمی‌شود. هیچ پاسخی اصلا کلید `next_cursor` ندارد، پس کلاینتی که آن را بخواند به‌جای رشته تهی هیچ‌چیز می‌گیرد، و `has_more` همیشه `false` است، حتی وقتی سطر بیشتری هست. `GET /v1/segments` و `GET /v1/campaigns` اصلا از این پاکت استفاده نمی‌کنند، و «بدون صفحه‌بندی» هم نیستند: هرکدام در خود SQL روی `ORDER BY updated_at DESC LIMIT 200` سقف خورده‌اند. این سقف بی‌صداست. نه تعدادی هست، نه `has_more`، نه هشداری، پس حسابی که ۲۵۰ سگمنت دارد ۲۰۰ تای تازه‌تر را می‌گیرد و روی هیچ سطحی مسیری ندارد که به آن ۵۰ تای دیگر برسد.

اگر ابزاری به شما گفت این فهرست‌ها صفحه‌بندی‌شده‌اند، اشتباه می‌کند. سرور تنها ابزار سنجش است.

---

## قفل نرم {#lock}

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

دو محرک دارد:

| مقدار `reason` | چه وقت | یعنی |
|---|---|---|
| `overdue_75` | یک فاکتور **صادرشده**، ۷۵ روز کامل یا بیشتر از سررسیدش گذشته | یک نفر باید فاکتور را بپردازد |
| `usage_300` | مصرف به **سه برابر** پروفایل یا رویداد گنجانده‌شده در پلن رسیده، هرکدام بزرگ‌تر باشد | پلن باید ارتقا پیدا کند |

فاکتوری که مشتری گفته پرداخت شده و منتظر تایید بانکی است، محرک را خنثی می‌کند: قفل‌کردن حساب در آن انتظار، مشتری را بابت عقب‌ماندگی کار خودمان جریمه می‌کند.

روی این میزبان دقیقا چهار مسیر پشت قفل‌اند: `GET /v1/exports`، `POST /v1/exports`، `POST /v1/reports/funnel` و `POST /v1/reports/retention`.

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

حساب قفل‌شده این را می‌بیند:

```json
{
  "error": {
    "code": "account_locked",
    "message": "پرداخت این حساب ۷۵ روز از سررسید گذشته است",
    "details": { "reason": "overdue_75" }
  }
}
```

وضعیت `403` است، نه `402`. «Payment Required» در هیچ کلاینتی معنای توافق‌شده ندارد، و نیمی از این رد اصلا درباره پرداخت نیست؛ سه‌برابر سهمیه یک بدهی نیست.

سه چیز که یک یکپارچه‌سازی باید بداند:

- `account_locked` یعنی «گزارش و خروجی بسته است»، نه «حساب خاموش است». `POST /v1/events` و `POST /v1/messages` و ارسال کمپین همچنان کار می‌کنند. با این کد سراغ خاموش‌کردن کل اتصال نروید.
- روی `details.reason` شاخه بزنید، نه روی متن. متن از کاتالوگ می‌آید و فارسی است.
- حکم قفل تا یک دقیقه cache می‌شود. مشتری‌ای که همین حالا فاکتورش را پرداخت کرده، ممکن است تا یک دقیقه هنوز رد شود.

قفل **باز** شکست می‌خورد: هر خواندنی که نتواند جواب بدهد، حساب را باز می‌گذارد و یک هشدار لاگ می‌کند. و روی این سطح هیچ هشدار پیش از قفل وجود ندارد. وضعیت «معوق» از روز سی‌ویکم فقط روی پنل دیده می‌شود و در هیچ پاسخ این API نیست.

---

## GET /v1/status {#status}

بدون احراز هویت، بدون هزینه، بدون تماس با دیتابیس. تنها مسیری که وقتی همه‌چیز دیگر `503` می‌دهد هنوز جواب می‌دهد.

```bash
curl -i https://api.segmentic.net/v1/status
```

```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Cache-Control: no-store, no-cache, must-revalidate
X-Server-Time: 2026-08-07T09:12:41Z

{"status":"ok","service":"api","version":"1.4.2"}
```

`X-Server-Time` را با ساعت خودتان مقایسه کنید. اگر ساعت شما بیش از یک ساعت جلوتر باشد، هر `timestamp` که روی `POST /v1/events` بفرستید بی‌صدا به زمان دریافت منتقل می‌شود. رد نمی‌شود و هشدارش هم روی آن مسیر دور ریخته می‌شود، پس تنها راه دیدنش همین مقایسه است.

---

## شمای داده {#schema}

دو مسیر که می‌گویند این حساب واقعا چه فرستاده است. هر دو `event.read` می‌خواهند و هزینه ۵ دارند. هیچ‌کدام پارامتر نمی‌گیرند.

### GET /v1/schema/events {#schema-events}

```bash
curl -s https://api.segmentic.net/v1/schema/events \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "events": [
    {
      "name": "order_completed",
      "volume": 184203,
      "prop_keys": ["revenue", "order_id", "currency"],
      "last_seen": "2026-08-06"
    }
  ]
}
```

`events` همیشه آرایه است و هیچ‌وقت `null` نمی‌شود. `prop_keys` و `last_seen` وقتی خالی باشند حذف می‌شوند.

`last_seen` مفیدترین ستون این فهرست است. رویدادی با حجم بزرگ که آخرین‌بار سه هفته پیش دیده شده، یک یکپارچه‌سازی شکسته است و هیچ عدد دیگری این را نمی‌گوید. حجم به‌تنهایی تا یک ماه سالم به نظر می‌رسد، چون پنجره نود روزه است.

شکست: `503` با بدنه `{"error":"schema unavailable"}`. این پاکت پنل است، نه پاکت این API. رجوع کنید به [پاکت خطا](/docs/api/management#errors).

### GET /v1/schema/traits {#schema-traits}

```bash
curl -s https://api.segmentic.net/v1/schema/traits \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "traits": ["city", "email", "lifetime_value"],
  "schema": [
    { "name": "city", "kind": "string", "users": 812043 },
    { "name": "lifetime_value", "kind": "number", "users": 61233 }
  ]
}
```

`traits` عمدا آرایه ساده نام‌ها مانده است. این یک مسیر عمومی است و چیزی بیرون آن را به‌عنوان رشته پیمایش می‌کند؛ عوض‌کردن شکل عنصر، آن یکپارچه‌سازی را روی یک ارتقا و بدون هیچ خطایی جایی، می‌شکند. جواب کامل‌تر یک کلید دوم کنارش است.

`kind` یا `string` است یا `number`. ویژگی‌ای که هر دو شکل فرستاده شده، یک‌بار و به‌صورت `number` می‌آید: ستون عددی همان است که بازه را پشتیبانی می‌کند و ستون رشته‌ای هنوز به تساوی جواب می‌دهد.

### ویژگی‌هایی که لازم نیست بفرستید {#builtin-traits}

`traits` و `schema` می‌گویند رویدادهای خود شما چه چیزی حمل می‌کنند. کنارشان، `builtin` می‌گوید هر حسابی بدون فرستادن هیچ‌چیز روی چه چیزهایی می‌تواند فیلتر بگذارد، و `engagement` می‌گوید یک شرط تعامل با چه چیزهایی مقایسه می‌شود:

```json
{
  "builtin": [
    {
      "name": "has_push",
      "kind": "boolean",
      "operators": ["eq", "neq"],
      "label": "امکان پوش",
      "computed": true,
      "description": "اینکه این آدم اصلا می‌تواند پوش بگیرد یا نه. مخاطب پوشی که این شرط را نداشته باشد، بیشترش آدم‌هایی‌اند که هیچ‌وقت آن را نمی‌بینند."
    },
    {
      "name": "birthday",
      "kind": "date",
      "operators": ["is_set", "is_not_set"],
      "label": "تاریخ تولد",
      "computed": false,
      "description": "فقط وجود داشتن را جواب می‌دهد. برای خود سالگرد از days_until_birthday استفاده کنید.",
      "use": "days_until_birthday"
    }
  ],
  "engagement": {
    "metrics": [ { "name": "open_rate", "kind": "number", "operators": ["gt", "gte", "lt", "lte", "between"], "label": "نرخ باز کردن", "computed": true } ],
    "bands": ["champion", "dormant"]
  }
}
```

قبل از ساختن فیلتر از روی کلیدهای خام، این را بخوانید. سه تای این‌ها به سوال‌هایی جواب می‌دهند که آدم‌ها معمولا اشتباه جوابشان را می‌سازند:

| ویژگی | به چه چیزی جواب می‌دهد |
|---|---|
| `has_push` | اینکه این آدم اصلا می‌تواند پوش بگیرد. مخاطبی که این را نداشته باشد بیشترش آدم‌هایی‌اند که پیام را نمی‌بینند |
| `days_until_birthday` | چند روز تا تولد بعدی، امروز صفر. `birthday` ذخیره‌شده یک تاریخ در گذشته است و بعد از سال اول هیچ‌کس را نمی‌گیرد، برای همین `birthday` فقط وجود داشتن را جواب می‌دهد و در `use` همین را نام می‌برد |
| `days_since_last_seen` | پنجره غیرفعالی که با تقویم جلو می‌رود، به جای زمانی که روز نوشتن مخاطب منجمد شده |

`operators` همان مجموعه‌ای است که روی آن ویژگی کامپایل می‌شود، پس کسی که از داخل آن انتخاب کند نمی‌تواند شرطی بنویسد که سرور ردش کند. توجه کنید که ویژگی عددی `in` و `not_in` ندارد: مقدار فهرستی، فهرستی از رشته‌هاست.

`computed` برای ویژگی‌ای درست است که پلتفرم خودش حسابش می‌کند. `label` و `description` زبان درخواست را دنبال می‌کنند، پس برای خواندنشان به انگلیسی `Accept-Language: en` بفرستید. `description` فقط جایی هست که خود نام همه ماجرا را نمی‌گوید.

شکست: `503` با بدنه `{"error":"schema unavailable"}`.

---

### GET /v1/ingest/quality {#ingest-quality}

چه چیزی را از شما رد کردیم و چه چیزی را اصلاح کردیم، به تفکیک روز.

```bash
curl -s "https://api.segmentic.net/v1/ingest/quality?days=7"   -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "days": 7,
  "from": "2026-08-16",
  "to": "2026-08-23",
  "totals": { "rejected": 126867, "warned": 4102 },
  "rows": [
    {
      "day": "2026-08-22",
      "kind": "reject",
      "code": "missing_identity",
      "sdk": "segmentic-android",
      "app_id": 3,
      "count": 126867,
      "label": "نه user_id داشت نه anonymous_id، پس معلوم نیست رویداد چه کسی است"
    },
    {
      "day": "2026-08-22",
      "kind": "warn",
      "code": "generated_message_id",
      "field": "message_id",
      "sdk": "segmentic-js",
      "app_id": 1,
      "count": 4102,
      "label": "message_id فرستاده نشده بود، پس اگر همین رویداد دوباره بیاید تکراری شناخته نمی‌شود"
    }
  ]
}
```

`kind` یا `reject` است یا `warn`، و تفاوتشان مهم است: رد شدن یعنی رویداد از دست رفت، و هشدار یعنی نگهش داشتیم و چیزی را در آن عوض کردیم. `days` پیش‌فرض ۷ است و سقفش ۹۰ روز است، یعنی همان مدتی که این جدول سطر نگه می‌دارد.

`label` زبان درخواست را دنبال می‌کند، پس برای خواندنش به انگلیسی `Accept-Language: en` بفرستید. `code` این کار را نمی‌کند: آن نیمه ثابت ماجراست و یکپارچه‌سازی باید روی آن شرط بگذارد نه روی جمله.

هر ستون یا یک عدد است یا یک کد بسته. هیچ مقدار، هیچ شناسه و هیچ متن خطایی در این پاسخ نیست: مشتری‌ای که گزارش کیفیت خودش را می‌خواند نباید شماره تلفن کس دیگری را بخواند. نتیجه‌اش این هم هست که کلید ویژگی‌ای که فرستاده‌اید و نتوانستیم ذخیره‌اش کنیم، به شکل `field: "other"` می‌آید نه با نام خودش.

شکست: `503` با بدنه `{"error":"schema unavailable"}`.

روی نصبی که خواننده انبار داده ندارد اصلا سرو نمی‌شود. پاسخ خالی اینجا یعنی «هیچ‌وقت چیزی رد نشده»، و این تنها حرف غلطی است که این اندپوینت می‌توانست بزند.

---

## مخاطب بدون ذخیره {#audiences}

دو مسیر که روی یک فیلتر کار می‌کنند بدون اینکه چیزی ذخیره کنند. شیء ذخیره‌شده «سگمنت» است؛ این‌ها عملیات لحظه‌ای روی یک تعریف‌اند.

هر دو بدنه یکسانی می‌گیرند و سقف بدنه‌شان **۱ مگابایت** است، نه ۸ مگابایت بقیه این سطح:

```json
{
  "definition": {
    "version": 1,
    "root": {
      "kind": "group",
      "op": "and",
      "children": [
        {
          "kind": "trait",
          "trait": "city",
          "operator": "eq",
          "value": { "type": "string", "str": "تهران" }
        }
      ]
    }
  }
}
```

فیلد `limit` در این struct پذیرفته می‌شود و هیچ‌کدام از این دو هندلر آن را نمی‌خوانند. زبان کامل شرط‌ها در [ساخت سگمنت](/docs/segments) است. سقف‌های کامپایل: عمق حداکثر ۸، حداکثر ۲۰۰ گره، حداکثر ۱۰۰۰ مقدار در یک فهرست، حداکثر ۱۲۸ بایت برای یک کلید.

### POST /v1/audiences/validate {#audiences-validate}

مجوز `segment.read`، هزینه ۱. دیتابیس را اصلا لمس نمی‌کند.

```bash
curl -s -X POST https://api.segmentic.net/v1/audiences/validate \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
```

```json
{
  "valid": true,
  "description_fa": "کاربرانی که شهرشان تهران است"
}
```

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

فیلتر نامعتبر `422` است:

```json
{
  "error": {
    "code": "filter_invalid",
    "message": "segment: unsupported operator: \"nonsense\""
  }
}
```

پنل برای همین فیلتر `200` با `valid:false` جواب می‌دهد، که برای فرمی که کاربر در آن تایپ می‌کند درست است و برای یکپارچه‌سازی‌ای که مدیریت خطایش روی status شاخه می‌زند غلط. اینجا `422` است.

JSON بدشکل اینجا `400` با بدنه `{"error":"malformed JSON"}` می‌گیرد، یعنی پاکت پنل نه پاکت این API.

### POST /v1/audiences/count {#audiences-count}

مجوز `segment.read`، هزینه **۲۵**. شمارش دقیق است، نه نمونه‌گیری‌شده.

```bash
curl -s -X POST https://api.segmentic.net/v1/audiences/count \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{"definition":{"version":1,"root":{"kind":"trait","trait":"city","operator":"eq","value":{"type":"string","str":"تهران"}}}}'
```

```json
{
  "count": 61432,
  "approximate": false,
  "description": "کاربرانی که شهرشان تهران است",
  "took_ms": 812
}
```

`approximate` روی این مسیر همیشه `false` است و `sample_rate` هیچ‌وقت ست نمی‌شود، پس در پاسخ نمی‌آید.

> [!warn]
> نام کلید جمله فارسی اینجا `description` است، ولی روی `POST /v1/audiences/validate` و روی نوشتن سگمنت `description_fa` است. دو نام برای یک چیز. این یک ناسازگاری واقعی است و اگر یک تابع مشترک بنویسید که هر دو پاسخ را می‌خواند، باید هر دو کلید را بگردد.

خطاها همه در پاکت پنل‌اند: فیلتری که کامپایل نمی‌شود `400` با `{"error":"segment: ..."}` می‌گیرد (یعنی همان فیلتری که `validate` برایش `422` می‌داد)، و شکست انبار داده `503` با `{"error":"count unavailable"}`.

---

## سگمنت {#segments}

سگمنت شیء ذخیره‌شده است. پنج مسیر دارد و همه فقط وقتی ثبت می‌شوند که `features.segments` روشن باشد.

### GET /v1/segments {#segments-list}

مجوز `segment.read`، هزینه ۱.

```bash
curl -s https://api.segmentic.net/v1/segments \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "segments": [
    {
      "id": 12,
      "name": "تهرانی‌ها",
      "kind": "dynamic",
      "definition": { "version": 1, "root": { "kind": "trait", "trait": "city", "operator": "eq", "value": { "type": "string", "str": "تهران" } } },
      "description_fa": "کاربرانی که شهرشان تهران است",
      "last_size": 61432,
      "last_computed_at": "2026-08-06T09:00:00Z",
      "updated_at": "2026-08-06T09:00:00Z"
    }
  ]
}
```

`kind` یکی از `dynamic`، `static` یا `realtime` است.

`last_size` و `last_computed_at` روی سیم هستند و پر نمی‌شوند. قرار بود اندازه مخاطب را از آخرین باری که چیزی شمرده نگه دارند، تا صفحه فهرست برای باز شدن دویست کوئری روی انبار داده نزند. انبار تابعی دارد که این دو را می‌نویسد و هیچ‌جای محصول منتشرشده صدایش نمی‌زند، پس `last_size` روی هر سگمنتی `0` است و `last_computed_at` روی هر سگمنتی نمی‌آید. نمونه بالا شکل را نشان می‌دهد، نه آنچه دریافت می‌کنید. برای عدد واقعی، `POST /v1/audiences/count` را با تعریف همان سگمنت صدا بزنید و ۲۵ واحد بپردازید.

`segments` همیشه آرایه است. `?limit=` و `?cursor=` روی این مسیر بی‌صدا نادیده گرفته می‌شوند، و فهرست روی ۲۰۰ سگمنت تازه‌تر سقف می‌خورد بدون اینکه چیزی در پاسخ این را بگوید. شکست: `503` با `{"error":"segments unavailable"}`.

### GET /v1/segments/{id} {#segments-get}

مجوز `segment.read`، هزینه ۱. پاسخ یک شیء برهنه با همان شکل بالاست، بدون پوشش.

شناسه غیرعددی یا صفر `400` با `{"error":"invalid segment id"}` می‌گیرد. شناسه ناشناس یا مال حساب دیگر `404` با `{"error":"segment not found"}` می‌گیرد. شمول در store روی حساب است، پس شناسه حدس‌زده‌شده از شناسه حذف‌شده قابل تشخیص نیست.

### POST /v1/segments {#segments-create}

مجوز `segment.write`، هزینه ۱. سقف بدنه ۸ مگابایت.

```bash
curl -s -X POST https://api.segmentic.net/v1/segments \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "تهرانی‌ها",
    "definition": {
      "version": 1,
      "root": {
        "kind": "trait",
        "trait": "city",
        "operator": "eq",
        "value": { "type": "string", "str": "تهران" }
      }
    }
  }'
```

```json
{
  "id": 12,
  "name": "تهرانی‌ها",
  "description_fa": "کاربرانی که شهرشان تهران است"
}
```

وضعیت `201`. دو فیلد بیشتر ندارد: `name` که پس از trim نباید خالی باشد، و `definition` که باید از اعتبارسنجی رد شود.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `malformed_json` | بدنه JSON معتبر نیست |
| `400` | `name_required` | نام خالی است. پیام: `a segment needs a name` |
| `422` | `filter_invalid` | تعریف از اعتبارسنجی رد نشد |
| `503` | `segment_unavailable` | store نتوانست ذخیره کند |

فیلد `kind` در این struct وجود ندارد. هر سگمنتی که از این API ساخته شود `dynamic` است. **ساختن فهرست ثابت یا realtime از این API ممکن نیست.**

`tenant_id` در بدنه نادیده گرفته می‌شود، رد نمی‌شود. حساب از روی کلید ست می‌شود و فیلد بدنه اصلا خوانده نمی‌شود: بار داده‌ای که حساب دیگری را نام می‌برد باید هیچ اثری نداشته باشد، و این خاصیت نخواندن فیلد است نه خاصیت بررسی‌کردنش.

> [!note]
> فیلدهای ناشناس در بدنه پذیرفته و نادیده گرفته می‌شوند. تنها مسیری روی این میزبان که فیلد ناشناس را رد می‌کند `POST /v1/messages` است. یعنی روی بقیه، غلط املایی در نام یک فیلد نامرئی است.

### PUT /v1/segments/{id} {#segments-update}

مجوز `segment.write`، هزینه ۱. همان بدنه ساخت.

این **جایگزینی کامل شیء** است، نه ادغام. `PATCH` وجود ندارد و هیچ `If-Match` یا نشانه نسخه‌ای در کار نیست، پس دو نویسنده هم‌زمان بی‌صدا روی هم می‌نویسند.

سه رفتار که باید بدانید:

1. اعتبارسنجی تعریف **پیش از** بررسی وجود سگمنت اجرا می‌شود. فیلتر نامعتبر روی شناسه‌ای که وجود ندارد هم `422` می‌گیرد.
2. سگمنت اول خوانده می‌شود. شناسه ناشناس یا مال حساب دیگر `404` با کد `not_found` می‌گیرد، نه یک نوشتن که بی‌صدا سگمنت تازه بسازد.
3. `name` خالی یا حذف‌شده، **نام قبلی را نگه می‌دارد**. پاکش نمی‌کند و خطا هم نمی‌دهد.

پاسخ `200` با همان سه کلید ساخت. شناسه غیرمثبت `400` با کد `bad_id` و پیام `the path must carry a positive integer id` می‌گیرد.

### DELETE /v1/segments/{id} {#segments-delete}

مجوز **`segment.delete`**، هزینه ۱. مجوز خودش را دارد چون برداشتن مخاطبی که سناریوی یک نفر به آن ارجاع می‌دهد، همان کار ویرایش‌کردن یکی نیست.

```bash
curl -s -i -X DELETE https://api.segmentic.net/v1/segments/12 \
  -H "Authorization: Bearer sk_seg_..."
```

پاسخ `204` بدون بدنه. **برای شناسه ناشناس `404` وجود ندارد**: حذف کورکورانه صدا زده می‌شود و یک no-op موفق هم `204` می‌گیرد. **رد `segment_in_use` هم وجود ندارد**؛ حذف مخاطبی که یک کمپین زمان‌بندی‌شده به آن اشاره می‌کند، یک تماس معمولی است.

نقشی که این مجوز را ندارد `403` با `need: "segment.delete"` می‌گیرد. شکست store، `503` با کد `segment_unavailable`.

---

## کمپین {#campaigns}

پنج مسیر، همه فقط وقتی `features.campaigns` روشن باشد. ساختن و فرستادن دو تماس روی دو مجوزند، دقیقا همان‌طور که در پنل دو دکمه‌اند. یک «بساز و بفرست» واحد، کار برگشت‌پذیر را در کار برگشت‌ناپذیر جمع می‌کرد.

### GET /v1/campaigns {#campaigns-list}

مجوز `campaign.read`، هزینه ۱. صفحه‌بندی نمی‌شود، و روی ۲۰۰ کمپین تازه‌تر سقف می‌خورد بدون اینکه چیزی در پاسخ این را بگوید.

```json
{
  "campaigns": [
    {
      "id": 5,
      "name": "پوش نوروز",
      "channel": "push",
      "status": "draft",
      "estimated": 61432,
      "processed": 0,
      "sent": 0,
      "scheduled_at": "2026-03-20T06:00:00Z",
      "updated_at": "2026-08-06T09:00:00Z"
    }
  ]
}
```

وضعیت‌های ممکن: `draft`، `scheduled`، `running`، `paused`، `completed`، `cancelled`، `failed`. شکست: `503` با `{"error":"campaigns unavailable"}`.

### GET /v1/campaigns/{id} {#campaigns-get}

مجوز `campaign.read`، هزینه **۵**. این گزارش کمپین است، نه فقط رکورد آن.

```json
{
  "campaign": {
    "id": 5,
    "tenant_id": 7,
    "name": "پوش نوروز",
    "channel": "push",
    "template_id": 3,
    "segment_id": 12,
    "status": "completed",
    "goal_event": "order_completed"
  },
  "progress": {
    "campaign_id": 5,
    "cursor": "u-98213",
    "estimated": 61432,
    "processed": 61432,
    "sent": 58210,
    "suppressed": 1802,
    "deferred": 0,
    "failed": 1420,
    "holdout": 0,
    "started_at": "2026-03-20T06:00:00Z",
    "updated_at": "2026-03-20T06:41:00Z",
    "finished_at": "2026-03-20T06:41:00Z"
  },
  "percent": 100,
  "reach": [
    { "status": "suppressed", "reason": "no_address", "reason_fa": "نشانی ندارد", "count": 1802 }
  ],
  "delivery": [
    { "delivery": "delivered", "delivery_fa": "تحویل شد", "count": 55012 }
  ],
  "engagement": [
    {
      "channel": "push",
      "channel_fa": "اعلان",
      "issued": 58210,
      "withheld": 0,
      "measurable_open": 58210,
      "measurable_click": 58210,
      "opened": 19204,
      "clicked": 4102,
      "opened_unmeasurable": 0,
      "clicked_unmeasurable": 0
    }
  ],
  "engagement_rejects": [],
  "uplift": {
    "verdict": "too_early",
    "verdict_fa": "در حال جمع‌آوری نتیجه",
    "goal": "order_completed",
    "treated_users": 0,
    "treated_conversions": 0,
    "control_users": 0,
    "control_conversions": 0,
    "contaminated": 0,
    "lift": 0,
    "lift_low": 0,
    "lift_high": 0,
    "extra_low": 0,
    "extra": 0,
    "extra_high": 0,
    "median_order": 0,
    "currency": "",
    "extra_revenue": 0,
    "extra_revenue_low": 0,
    "extra_revenue_high": 0,
    "money_known": false,
    "needed_per_arm": 0,
    "window_closed_at": "2026-03-27T06:41:00Z",
    "computed_at": "0001-01-01T00:00:00Z"
  }
}
```

سه چیز که این پاسخ را از یک progress bar جدا می‌کند:

- `reach` جواب پرسشی است که چنین سکویی مدام از آن پرسیده می‌شود و معمولا نمی‌تواند جواب بدهد: سگمنت شصت‌هزار گفت، چرا چهل‌ویک‌هزار نفر گرفتند.
- هر نرخ در `engagement` به‌صورت صورت و مخرج نام‌دار می‌آید، نه درصد. کمپینی که پیامش لینک نداشته، کمپین با نرخ کلیک صفر نیست، و `measurable_click` همان چیزی است که این را می‌گوید. یک درصد ترکیبی واحد، عددی است که مشتری نمی‌تواند بازتولیدش کند.
- `percent` روی اجرای تمام‌شده همیشه صد است و از صد بالاتر نمی‌رود. تخمین نمونه‌گیری‌شده است، پس یک اجرا می‌تواند از آن رد شود، و نمایش ۱۱۸ درصد مثل یک باگ خوانده می‌شود.

`uplift` به‌محض تمام‌شدن کمپین می‌آید، نه پس از بسته‌شدن پنجره انتساب. تا وقتی اندازه‌گیری ذخیره نشده باشد این بخش ساخته می‌شود، دقیقا همان‌طور که نمونه بالا نشان می‌دهد: `verdict` برابر `too_early` است، `verdict_fa` و `goal` پر می‌شوند، `window_closed_at` برابر `finished_at` به‌علاوه هفت روز است، `computed_at` به شکل `0001-01-01T00:00:00Z` روی سیم می‌آید، و هر فیلد عددی صفر است. برای گرفتن جواب روی `window_closed_at` حساب نکنید. این تاریخ زودترین زمان ممکن آمدن جواب است، نه زمان آمدنش: اندازه‌گیری هفت روز از آخرین پیامی که واقعا بیرون رفته صبر می‌کند، و کمپین با ساعت محلی تا یک روز و نیم بعد از تمام‌شدن اجرا هنوز پیام می‌فرستد، پس بخش `too_early` می‌تواند بعد از گذشتن آن تاریخ هم سرو شود. فقط `positive` و `negative` و `inconclusive` اثر افزودهٔ اندازه‌گیری‌شده دارند. `no_control` و `contaminated` محاسبه را پیش از کم‌کردن دو نرخ متوقف می‌کنند، پس `lift` و `lift_low` و `lift_high` و همه فیلدهای `extra` روی آن سطرها هم صفر می‌آیند؛ صفر آنجا یعنی چیزی برای مقایسه نیست، نه اینکه اثر صفر بوده. ولی شمارش‌های کنارش روی هر حکمی واقعی‌اند: شمار تیمارشده و کنترل پیش از متوقف‌شدن محاسبه نوشته می‌شوند، و روی سطر `contaminated` خود شمار آلوده دلیل وجود همان حکم است. نصبی که ورکر کمپین را اجرا نمی‌کند هیچ‌وقت اندازه‌گیری ذخیره نمی‌کند، پس همیشه همین بخش ساختگی را سرو می‌کند. `reach`، `delivery`، `engagement` و `uplift` همه best-effort اند: یک اختلال در انبار داده هزینه‌اش آن بخش است، نه کل صفحه.

توجه کنید که `tenant_id` اینجا روی سیم هست، برخلاف شیء سگمنت.

خطاها: `400` با `{"error":"invalid campaign id"}` و `404` با `{"error":"campaign not found"}`، هر دو در پاکت پنل.

### POST /v1/campaigns {#campaigns-create}

مجوز `campaign.write`، هزینه ۱.

```bash
curl -s -X POST https://api.segmentic.net/v1/campaigns \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "پوش نوروز",
    "channel": "push",
    "template_id": 3,
    "segment_id": 12,
    "scheduled_at": "2026-03-20T06:00:00Z",
    "control_group_pct": 5,
    "goal_event": "order_completed"
  }'
```

```json
{ "id": 5, "status": "draft" }
```

وضعیت `201`.

| فیلد | نوع | لازم | قاعده |
|---|---|---|---|
| `name` | رشته | خیر | اصلا اعتبارسنجی نمی‌شود. نام خالی پذیرفته می‌شود |
| `channel` | رشته | عملا بله | باید از فهرست پایین باشد. رشته خالی یعنی «الگو تصمیم می‌گیرد» |
| `template_id` | عدد | بله | صفر یعنی `campaign: a template is required` |
| `channels` | آرایه | خیر | **کل زنجیره به ترتیب**، هر کدام `{channel, template_id}`، و ورودی اولش باید خود `channel` باشد. پایین‌تر توضیح داده شده |
| `segment_id` | عدد | خیر | صفر یعنی مخاطب همان `definition` درون‌خطی است |
| `definition` | شیء | خیر | **اینجا اعتبارسنجی نمی‌شود**، برخلاف سگمنت |
| `topic_id` | عدد | خیر | صفر یعنی بدون موضوع اشتراک |
| `scheduled_at` | RFC3339 | خیر | مقدار غیرقابل‌خواندن **بی‌صدا حذف می‌شود**، خطا نمی‌دهد |
| `use_local_time` | بولین | خیر | پیش‌فرض `false` |
| `local_hour` | عدد | خیر | فقط وقتی `use_local_time` روشن باشد باید بین ۰ و ۲۳ باشد |
| `throttle_minutes` | عدد | خیر | اعتبارسنجی ندارد |
| `control_group_pct` | عدد اعشاری | خیر | باید بین ۰ و ۱۰۰ باشد |
| `audience_pct` | عدد اعشاری | خیر | برش پایلوت. باید بین ۰ و ۱۰۰ باشد، و **۰ یعنی همه**، نه هیچ‌کس |
| `goal_event` | رشته | خیر | خالی یعنی `order_completed` |

`channels` **کل زنجیره است، نه جایگزین‌های بعد از کانال اول**. اگر بدهیدش، ورودی اولش باید دقیقا همان چیزی باشد که در `channel` نوشته‌اید، وگرنه `422` می‌گیرید. دلیل این سخت‌گیری یک اشتباه بی‌صداست: وقتی `channels` پر باشد، مسیر ارسال فقط همان را می‌خواند و `channel` را اصلا نگاه نمی‌کند، پس `channel: "sms"` به‌همراه `channels: [push, inapp]` کمپینی می‌سازد که پوش و درون‌برنامه می‌فرستد و **هیچ‌وقت پیامک نمی‌فرستد**، و هیچ خطایی هم جایی ثبت نمی‌شود.

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

هر ورودی `template_id` خودش را می‌خواهد که برای همان کانال نوشته شده باشد. یک قالب نمی‌تواند به دو کانال خدمت کند: پیامک هفتاد کاراکتر فارسی است و پوش عنوان دارد، و مشترک کردنشان همان‌جایی است که پیامکی با متن پوش بیرون می‌رود. یک کانال دو بار نمی‌آید، و کمپینی که تست A/B دارد اصلا زنجیره نمی‌گیرد، چون قالب هر نسخه روی همه کانال‌های زنجیره فرستاده می‌شود.

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

`audience_pct` کمپین را به یک برش ثابت از کسانی می‌فرستد که مخاطبش را
می‌سازند، تا یک کمپین واقعی قبل از رفتن به همه، به یک درصد برود. یک نفر همیشه
یک‌جور می‌افتد، پس اجرایی که وسط کار متوقف و بعد از سر گرفته شود، ترکیب پایلوت
را به هم نمی‌زند.

این گروه کنترل با عددهای برعکس نیست. holdout گروهی است که از آن دریغ می‌شود تا
اثر کمپین در برابرش سنجیده شود؛ پایلوت گروهی است که دریافت می‌کند. این دو
مستقل از هم قرعه می‌خورند، پس یک کمپین می‌تواند هر دو را داشته باشد.

کسانی که بیرون پایلوت می‌مانند در شمارنده‌ی خودشان، `progress.outside_pilot`،
گزارش می‌شوند و به `suppressed` اضافه نمی‌شوند: پایلوتی که دقیقا همان کاری را
کرده که به آن گفته شده، نباید مثل کمپینی خوانده شود که governance جلویش را
گرفته است.

`external_ref` در خواندن برمی‌گردد و از اینجا قابل تنظیم نیست. این فیلد نام
کمپین در سیستمی است که مشتری از آن مهاجرت کرده، و ابزار اپراتوری دو دفتر را
روی همان به هم می‌چسباند.

کانال‌های پذیرفته‌شده: `push`، `webpush`، `sms`، `email`، `inapp`، `messenger`، `webhook`. مستعارهای `web`، `p`، `s`، `e`، `w` و `i` هم resolve می‌شوند. سه پیام‌رسان `bale`، `eitaa` و `rubika` پذیرفته و به `messenger` تا می‌شوند، چون بازاریاب نمی‌تواند بداند هرکدام از دو میلیون نفر کدام اپ را نصب کرده. هر چیز دیگری رد می‌شود، نه اینکه به پیش‌فرض بیفتد: کانال اشتباهی که موفقیت گزارش می‌کند بدتر از یک `400` است که نام فیلد را می‌گوید.

> [!danger]
> **`webhook` از این اعتبارسنجی رد می‌شود و هیچ فرستنده‌ای پشتش نیست.** در فهرست کانال‌های قابل‌کمپین هست، پس کمپین ساخته و زمان‌بندی و اجرا می‌شود، و در لایه تحویل هیچ کدی این کانال را اصلا نمی‌شناسد. کمپینی که روی `webhook` نوشته شود برای کل مخاطبش شکست می‌خورد. تا وقتی همین صفحه چیز دیگری نگفته، از آن استفاده نکنید.

**وضعیت هرچه بفرستید `draft` می‌شود.** کلید `status` در بدنه اصلا خوانده نمی‌شود.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `malformed_json` | بدنه JSON معتبر نیست |
| `400` | `invalid_channel` | پیام `unknown channel "bogus"`، و `details` برابر `{"field":"channel"}` |
| `422` | `campaign_invalid` | متن خطای اعتبارسنجی کمپین |
| `503` | `campaign_unavailable` | store نتوانست ذخیره کند |

### PUT /v1/campaigns/{id}/recurrence {#campaigns-recurrence-set}

مجوز `campaign.send`، هزینه ۱. این مسیر برنامه تکرار خودکار یک کمپین ذخیره‌شده را شروع یا جایگزین می‌کند. هر نوبت، کمپین تازه‌ای با همان مخاطب و محتوا می‌سازد.

همه فیلدهای تقویمی با ساعت تهران خوانده می‌شوند. روز ماه، روز تقویم جلالی است. در برنامه هفتگی، شنبه `0` و جمعه `6` است.

```bash
curl -s -X PUT https://api.segmentic.net/v1/campaigns/5/recurrence \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "recurrence": {
      "cadence": "weekly",
      "hour": 9,
      "day_of_week": 0,
      "max_occurrences": 4
    }
  }'
```

مقدار `cadence` یکی از `daily`، `weekly`، `monthly` یا `yearly` است. یا `hour` را بدهید، یا `hours` را با حداکثر شش مقدار از `0` تا `23`. برنامه هفتگی از `day_of_week` استفاده می‌کند. `day_of_month` بین `1` تا `31` را هم برنامه ماهانه و هم سالانه می‌خواند، و `month` بین `1` برای فروردین تا `12` برای اسفند فقط برای سالانه لازم است و بقیه نادیده‌اش می‌گیرند. مقدار اختیاری `ends_at` با قالب RFC3339 یا `max_occurrences` مثبت، مجموعه را متوقف می‌کند. بدون این دو، برنامه تا زمان پاک شدن ادامه دارد.

برنامه سالانه همان چیزی است که برای یک تاریخ تقویمی می‌خواهید: روز یک صنف، نوروز، سالگرد باز شدن یک حساب. همه‌چیز اینجا جلالی و به وقت تهران است، پس `month: 12, day_of_month: 5` یعنی ۵ اسفند هر سال. روزی که از انتهای یک ماه کوتاه بگذرد روی آخرین روز همان ماه می‌نشیند نه اول ماه بعد، پس `day_of_month: 30` در اسفند سال عادی روی ۲۹ اسفند می‌رود: منظور کسی که آن را تایپ کرده «آخر سال» بوده، و نوروز تنها روزی است که پیام آخر سال نباید در آن برسد.

درخواست نمی‌تواند `occurrences` را تعیین کند. این شمارنده صفر می‌شود و خود کارگر آن را نگه می‌دارد.

```json
{ "status": "ok" }
```

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `bad_id` یا `malformed_json` | شناسه یا بدنه خوانده نمی‌شود |
| `422` | `recurrence_invalid` | تناوب، ساعت، روز هفته یا روز ماه نامعتبر است |
| `503` | `recurrence_unavailable` | برنامه ذخیره نشد |

### DELETE /v1/campaigns/{id}/recurrence {#campaigns-recurrence-clear}

مجوز `campaign.write`، هزینه ۱، نه `campaign.send`. این مسیر تکرارهای خودکار آینده را متوقف می‌کند. کمپین‌هایی که برنامه قبلا ساخته، تغییر نمی‌کنند و پاک نمی‌شوند.

شروع کردن یک برنامه `campaign.send` می‌خواهد، چون هر نوبتش کمپین تازه‌ای است که می‌تواند به کل مخاطب برسد. متوقف کردنش فقط از حجم ارسال کم می‌کند، پس بیش از مجوز ویرایش کمپین نمی‌خواهد، و این همان تفکیکی است که پنل برای pause و cancel قائل است. قبلا این مسیر هم `campaign.send` می‌خواست و هزینه‌اش دقیقا برعکس می‌افتاد: کلیدی که عمدا بدون `campaign.send` ساخته شده باشد، یعنی همان کلیدی که کار روزمره باید با آن انجام شود، تنها کلیدی بود که نمی‌توانست برنامه‌ای را که هر روز کمپین می‌ساخت متوقف کند.

```bash
curl -s -X DELETE https://api.segmentic.net/v1/campaigns/5/recurrence \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "status": "ok",
  "note": "campaigns already created by this schedule are unchanged"
}
```

خطاها `400 bad_id` و `503 recurrence_unavailable` هستند.

### POST /v1/campaigns/{id}/send {#campaigns-send}

مجوز `campaign.send`، هزینه ۱. بدنه‌ای خوانده نمی‌شود. این تماس برگشت‌ناپذیر است.

```bash
curl -s -X POST https://api.segmentic.net/v1/campaigns/5/send \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{ "id": 5, "status": "scheduled" }
```

وضعیت `202`.

اگر حساب تایید کمپین را لازم کرده باشد، دروازه تایید اول اجرا می‌شود. APIای که به یک یکپارچه‌سازی اجازه بدهد از بازبینی‌ای که پنل اعمال می‌کند رد شود، آن بازبینی را تزیینی می‌کند، و یکپارچه‌سازی دقیقا جایی است که آدم برای دور زدنش سراغش می‌رود.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `bad_id` | شناسه غیرمثبت در مسیر |
| `409` | `approval_required` | حساب تایید می‌خواهد و تاییدی نیست، یا رد شده، یا هنوز تایید نشده |
| `409` | `approval_stale` | کمپین بعد از تایید عوض شده. دوباره برای تایید بفرستید |
| `404` | `not_found` | کمپین خوانده نشد |
| `503` | `approval_unavailable` | خواندن قاعده یا وضعیت تایید شکست خورد. **بسته** شکست می‌خورد |
| `503` | `campaign_unavailable` | زمان‌بندی شکست خورد |

`approval_stale` کد جداگانه دارد چون این دو، یکپارچه‌سازی را به دو جای متفاوت می‌فرستند: یکی به «تایید بگیر»، دیگری به «یک نفر بعد از تایید این را ویرایش کرده». اثر انگشت روی شناسه سگمنت، تعریف درون‌خطی، الگو، کانال، موضوع، رویداد هدف، درصد گروه کنترل، زمان‌بندی و فهرست مرتب‌شده واریانت‌ها گرفته می‌شود و ۳۲ نویسه شانزده‌شانزدهی است.

> [!danger]
> **هیچ راهی برای مکث، ادامه یا لغو کمپین روی این میزبان وجود ندارد.** هر سه روی پنل هستند و روی این mux ثبت نشده‌اند. وقتی بک‌اند شما یک ارسال را زمان‌بندی کرد، فقط پنل می‌تواند جلویش را بگیرد.

### POST /v1/campaigns/{id}/submit {#campaigns-submit}

مجوز `campaign.write`، نه `campaign.approve`: نویسنده دارد درخواست می‌کند، نه تصمیم می‌گیرد. هزینه ۱. فقط وقتی `features.campaign_approval` روشن باشد ثبت می‌شود. بدنه‌ای خوانده نمی‌شود.

```json
{
  "approval_id": 88,
  "state": "pending",
  "fingerprint": "3f1a9c02b7de4415aa0e8c1d2f6b3790"
}
```

وضعیت `202`. مقادیر `state`: `pending`، `approved`، `rejected`، `stale`.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `bad_id` | شناسه غیرمثبت |
| `404` | `not_found` | کمپین وجود ندارد |
| `409` | `not_submittable` | وضعیت کمپین `draft` یا `paused` نیست |
| `422` | `campaign_invalid` | کمپین از اعتبارسنجی رد نشد |
| `503` | `approval_unavailable` | ثبت درخواست شکست خورد |

درخواست به **سازنده کلید** نسبت داده می‌شود، نه به خود کلید. قاعده دو نفره درباره آدم‌هاست، و نسبت‌دادن یک درخواست به «کلید شماره ۱۲» به یک نفر اجازه می‌داد با ساختن یک کلید هر دو نیمه را خودش نگه دارد. اگر سازنده کلید حذف شده باشد، این مقدار صفر است.

> [!warn]
> **مسیری برای تایید کردن، دیدن صف تایید یا خواندن تاریخچه تایید روی این میزبان وجود ندارد.** یک یکپارچه‌سازی می‌تواند بازبینی بخواهد و بعد باید منتظر یک آدم در پنل بماند. تنها راه برنامه‌ای برای فهمیدن جواب، تلاش دوباره برای ارسال و خواندن کد `409` است.

---

## POST /v1/events {#events}

مجوز **`profile.write`**، هزینه ۵، سقف بدنه ۸ مگابایت. فقط وقتی `features.ingest` روشن باشد ثبت می‌شود.

`profile.write` انتخاب شده و نه یک مجوز تازه، چون این کاری است که این مسیر می‌کند: روی پرونده آدم‌ها و تاریخچه رویدادشان می‌نویسد، و ساختن نام دوم برای همان توانایی باعث می‌شد کسی یکی را بدهد به این خیال که دیگری را نگه داشته است. نقش‌هایی که دارندش: `owner`، `admin`، `marketer`.

```bash
curl -s -X POST https://api.segmentic.net/v1/events \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "type": "track",
        "event": "order_completed",
        "user_id": "u-1",
        "message_id": "srv-order-8821",
        "timestamp": "2026-08-06T09:12:41Z",
        "properties": { "revenue": 480000, "order_id": "8821", "currency": "IRR" }
      }
    ]
  }'
```

```json
{ "accepted": 1 }
```

وضعیت **`202`**، نه `200`. رویدادها در صف‌اند، نه ذخیره‌شده: در همان خط لوله‌ای هستند که رویدادهای SDK از آن می‌گذرند و چند ثانیه بعد قابل کوئری می‌شوند. گفتن `200` کسی را دعوت می‌کرد که بلافاصله آن‌ها را بخواند و نتیجه بگیرد گم شده‌اند.

اگر بخشی از batch رد شود:

```json
{
  "accepted": 2,
  "rejected": [
    { "index": 1, "reason": "missing_identity" }
  ]
}
```

`index` شماره در آرایه **شما**ست، تا منطق تلاش دوباره‌تان بتواند آیتم را بدون تطبیق محتوا پیدا کند. رویدادهای شما ممکن است اصلا هنوز شناسه نداشته باشند، که نیمی از دلیل رد شدنشان است.

هر آیتم **اینجا** اعتبارسنجی می‌شود، پیش از اینکه sink ببیندش. sink پشت این مسیر همان sinkای است که واردکننده CSV استفاده می‌کند و آنچه را نتواند نرمال کند دور می‌اندازد، که برای واردکننده‌ای که سطرهای خودش را از قبل اعتبارسنجی کرده درست است و برای مسیری که JSON دلخواه از اینترنت می‌گیرد غلط. بدون این حلقه، مشتری پانصد رویداد می‌فرستاد، `200` می‌گرفت، و یک هفته بعد چهارصدتایشان را غایب پیدا می‌کرد بدون اینکه هیچ‌چیز جایی توضیحش بدهد.

ترتیب ردها:

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `malformed_json` | بدنه JSON معتبر نیست |
| `400` | `batch_empty` | آرایه `events` خالی است |
| `413` | `batch_too_large` | بیش از ۵۰۰ رویداد. `details` برابر `{"limit":500,"sent":501}` |
| `402` | `quota_cancelled` یا `quota_trial_over` یا `quota_event_cap` | سقف تجاری. کل batch رد می‌شود |
| `422` | `all_events_rejected` | هیچ آیتمی قابل قبول نبود. `details` همان آرایه ردهاست |
| `503` | `ingest_unavailable` | صف در دسترس نبود. پیام: `could not queue these events; retry` |

سقف در متن رد منتشر می‌شود، تا کلاینتی که اندازه حلقه‌اش را تعیین می‌کند مجبور نباشد آن را با دوبخشی‌کردن کشف کند. رد `402` را **تکرار نکنید**: تا کسی پرداخت نکند یا دوره عوض نشود چیزی تغییر نمی‌کند. بررسی سهمیه خودش **باز** شکست می‌خورد؛ خطای جست‌وجو، batch را قبول می‌کند و لاگ می‌نویسد، چون سهمیه برای جلوگیری از یک صورتحساب فراری است و از دست دادن رویدادهای مشتری بابت یک چشمک Postgres حادثه بزرگ‌تری است.

### هشت تفاوت با میزبان ورود داده {#events-differences}

این مسیر و `POST /v1/batch` روی `in.segmentic.net` یک کار نمی‌کنند. اگر تاریخچه مهاجرت می‌دهید، سطر آخر را حتما بخوانید.

| | `POST /v1/batch` روی میزبان ورود | `POST /v1/events` اینجا |
|---|---|---|
| کلید | `wk_seg_...` عمومی | `sk_seg_...` محرمانه |
| کلید آرایه در بدنه | `batch` | `events` |
| وضعیت موفق | `200` | `202` |
| `rejected` | یک عدد، آرایه زیر `errors` | خود آرایه |
| هشدارها | برمی‌گردند، حداکثر ۵۰ | **کامل دور ریخته می‌شوند** |
| حذف تکراری با `message_id` | **بله** | **خیر.** batch تکراری اینجا دوبار شمرده می‌شود |
| IP و User-Agent | خوانده و برای موقعیت جغرافیایی استفاده می‌شود | **عمدا ست نمی‌شود.** این تماس سرور به سرور است، پس نشانی مال دیتاسنتر مشتری است و نسبت‌دادن شهر گیرنده از رویش، همه کاربران او را در یک نقطه می‌گذارد |
| پنجره زمان گذشته | از سیاست نگه‌داری همان حساب | **ست نمی‌شود، پس پیش‌فرض ۳۰ روز اعمال می‌شود** |

> [!danger]
> **هر `timestamp` قدیمی‌تر از سی روز روی این مسیر بی‌صدا به دقیقا سی روز پیش منتقل می‌شود.** رد نمی‌شود، هشدار هم نمی‌گیرید. مهاجرت دو سال تاریخچه از این در، همه‌اش را روی یک تاریخ روی هم می‌چیند و اولین نشانه‌اش قیفی است که ماه‌ها بعد بی‌معنی به نظر می‌رسد. برای بارگذاری تاریخی از مسیر backfill پنل استفاده کنید.

### نکته درباره `reason` {#events-reason}

کدهای پایدار رد این‌ها هستند: `unknown_type`، `missing_identity`، `missing_event_name`، `event_name_too_long`، `event_name_invalid_chars`، `id_too_long`، `missing_previous_id`، `timestamp_too_old`.

ولی مقدار `reason` در پاسخ، متن کامل خطاست و دو مورد با مقدار خود شما پیچیده می‌شوند. مثلا `unknown_type: "not_a_type"`.

پس روی برابری تطبیق ندهید. روی پیشوند تطبیق بدهید یا روی `": "` بشکنید. متریک سمت ما کران‌دار است، مقدار روی سیم نه.

---

## قالب پیام {#templates}

قالب، متن پیام است با جاهای خالی. کمپین و گره ارسال سناریو هر دو با `template_id`
به آن اشاره می‌کنند و هیچ‌کدام نسخه‌ای از متن را در خودشان نگه نمی‌دارند، پس
قالب تنها جایی است که متن عوض می‌شود.

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

**حذف ندارد و ویرایش با URL هم ندارد.** حذف یک قالب، سناریوی در جریانی را که به
آن اشاره می‌کند بی‌صدا می‌شکند، و پنل هم حذف ندارد. ویرایش همان `POST` است با
`id`، دقیقا مثل پنل: یک در، و همان یک در.

### GET /v1/templates {#templates-list}

مجوز `template.read`، هزینه ۱.

```bash
curl -s https://api.segmentic.net/v1/templates \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "templates": [
    { "id": 42, "name": "خوش‌آمد پیامکی", "channel": "sms", "category": "marketing",
      "title": "", "body": "سلام {{name}}، خوش آمدید." }
  ]
}
```

### GET /v1/templates/{id} {#templates-get}

مجوز `template.read`، هزینه ۱. کل قالب، به‌علاوه سه چیزی که در فهرست نیست:

```json
{
  "id": 42,
  "channel": "sms",
  "category": "marketing",
  "title": "",
  "body": "سلام {{name}}، خوش آمدید.",
  "variables": ["name"],
  "pattern_code": "welcome_v2",
  "pattern_approved": true,
  "pattern_tokens": { "name": "1" }
}
```

`variables` همان جاهای خالی است که قالب لازم دارد، و همیشه آرایه است حتی وقتی
خالی باشد: نبودن کلید و آرایه خالی دو جواب مختلف به «این قالب چه می‌خواهد»
هستند.

`pattern_approved` مهم‌تر از آن است که به نظر می‌رسد. قالب پیامکی با الگوی
تأییدنشده، قالبی است که لحظه ارسال رد می‌شود، و فهمیدنش اینجا هیچ هزینه‌ای
ندارد.

`icon` هم برگردانده می‌شود، با اینکه `POST` نمی‌تواند آن را بنویسد. پنهان کردنش
رفت‌وبرگشت را بی‌تلفات نشان می‌داد در حالی که نیست، و کسی که قالبی را می‌خواند و
متنش را عوض می‌کند و برمی‌گرداند حق دارد فیلدی را که دارد از دست می‌دهد ببیند.

### POST /v1/templates {#templates-create}

مجوز `template.write`، هزینه ۱. سقف بدنه ۸ مگابایت.

```bash
curl -s -X POST https://api.segmentic.net/v1/templates \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "خوش‌آمد پیامکی",
    "channel": "sms",
    "category": "marketing",
    "body": "سلام {{name}}، خوش آمدید."
  }'
```

```json
{ "id": 42, "name": "خوش‌آمد پیامکی", "channel": "sms" }
```

وضعیت `201` برای ساخت و `200` وقتی `id` بدهید و قالب موجود جایگزین شود. نام در
هر حساب یکتاست.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `name_required` | نام پس از trim خالی است |
| `400` | `content_required` | نه عنوان دارد و نه متن |
| `400` | `invalid_channel` | کانال شناخته نشد |
| `503` | `template_unavailable` | ذخیره نشد |

### POST /v1/templates/render {#templates-render}

مجوز `template.read`، هزینه ۱. قالب را با مقدارهایی که می‌دهید پر می‌کند و
نشان می‌دهد چه چیزی فرستاده می‌شد. **چیزی فرستاده نمی‌شود و چیزی ذخیره نمی‌شود.**

`id` بدهید تا قالب ذخیره‌شده رندر شود، یا متن را درجا بفرستید تا پیش‌نویسی که
ذخیره نکرده‌اید سنجیده شود.

```bash
curl -s -X POST https://api.segmentic.net/v1/templates/render \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{ "id": 42, "vars": { "name": "سارا" } }'
```

```json
{
  "title": "",
  "body": "سلام سارا، خوش آمدید.",
  "missing": [],
  "sendable": true,
  "variables": ["name"],
  "sms": { "encoding": "ucs2", "parts": 1, "remaining": 47 }
}
```

`missing` فهرست متغیرهایی است که مقدار نگرفتند، و `sendable` می‌گوید با همین
مقدارها فرستادنی است یا نه. متغیر بی‌مقدار به رشته خالی تبدیل نمی‌شود: پیامی که
«سلام ،» می‌رود اشتباه رفته و پیامی که رد شده نرفته.

`sms` فقط برای کانال پیامک می‌آید و از **کانال ذخیره‌شده** حساب می‌شود نه از
چیزی که در درخواست نوشته شده، چون قالبی که پیامکی ذخیره شده پیامکی است و تعداد
بخش‌ها همان چیزی است که پول آن را می‌دهید.

## سناریو {#journeys}

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

**ذخیره و انتشار دو کار جداست و دو مجوز دارد.** ذخیره پیش‌نویس چیزی را برای کسی
نمی‌فرستد. انتشار، گراف را از همان لحظه جلوی هر کسی می‌گذارد که ماشه‌اش را
بزند، و برگشت ندارد: نمونه‌هایی که وارد شده‌اند وارد شده‌اند. برای همین
`journey.publish` را نقش‌های کمتری دارند تا `journey.write`.

### GET /v1/journeys {#journeys-list}

مجوز `journey.read`، هزینه ۱. فهرست سناریوها با وضعیت و نسخه منتشرشده و شمار
کسانی که همین حالا داخلشان هستند.

```bash
curl -s https://api.segmentic.net/v1/journeys \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "journeys": [
    { "id": 4, "name": "خوش‌آمد", "status": "active", "version": 3, "active": 812, "waiting": 40 }
  ]
}
```

`status` یکی از `draft` و `active` و `paused` و `archived` است. `active` تعداد
نمونه‌های در جریان است و `waiting` آن‌هایی که روی یک گره صبر پارک شده‌اند.

### GET /v1/journeys/{id} {#journeys-get}

مجوز `journey.read`، هزینه ۵. **نسخه منتشرشده** را برمی‌گرداند، به‌علاوه شمارنده
هر گره.

```json
{
  "graph": { "journey_id": 4, "version": 3, "entry_id": "n1", "nodes": [] },
  "stats": { "n1": { "entered": 900, "exited": 860, "suppressed": 12, "waiting": 28 } }
}
```

اگر سناریو هیچ نسخه منتشرشده‌ای نداشته باشد، `404` با کد `not_found` می‌گیرید.
همان جواب را برای شناسه‌ای که وجود ندارد هم می‌گیرید، و این عمدی است: تفاوت آن دو
یعنی گفتن اینکه کدام شناسه‌ها واقعی‌اند.

شمارنده‌ها بهترین‌کوشش‌اند. اگر انبار داده جواب ندهد، `stats` خالی برمی‌گردد و
`graph` سر جایش است، چون گراف چیزی است که خواستید.

### POST /v1/journeys {#journeys-save}

مجوز `journey.write`، هزینه ۱. پیش‌نویس را ذخیره می‌کند. **چیزی منتشر نمی‌شود.**

```bash
curl -s -X POST https://api.segmentic.net/v1/journeys \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "خوش‌آمد",
    "graph": {
      "entry_id": "n1",
      "nodes": [
        { "id": "n1", "kind": "trigger", "trigger": { "event": "signed_up" }, "next": "n2" },
        { "id": "n2", "kind": "send", "send": { "channel": "sms", "template_id": 42 } }
      ]
    }
  }'
```

```json
{ "id": 4, "name": "خوش‌آمد", "live_version": 0, "status": "draft", "problems": [] }
```

`id` را ننویسید تا ساخته شود، یا شناسه یک سناریوی موجود را بنویسید تا
پیش‌نویسش جایگزین شود. **جایگزینی است نه ادغام:** گراف کامل را بفرستید.

`live_version` نسخه‌ای است که همین حالا در حال اجراست، نه چیزی که الان ذخیره
کردید. ذخیره پیش‌نویس آن عدد را عوض نمی‌کند و صفر یعنی هنوز هیچ نسخه‌ای منتشر
نشده. هر دو برگردانده می‌شوند تا کسی ذخیره را با انتشار اشتباه نگیرد.

گراف نیمه‌ساخته **ذخیره می‌شود**، دقیقا مثل پنل، چون ساختن یک سناریو چند نشست
طول می‌کشد. به‌جای رد کردن، فهرست ایرادها در `problems` همان پاسخ برمی‌گردد:
خالی یعنی قابل انتشار است، و هر چه در آن باشد دلیلی است که فراخوان انتشار رد
می‌کند. آن رد، همان‌جا می‌افتد که باید.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `name_required` | `name` نیامده |
| `400` | `graph_required` | `graph` نیامده |
| `409` | `name_taken` | این حساب از قبل ژورنی‌ای با همین نام دارد |
| `503` | `journey_unavailable` | store نتوانست ذخیره کند |

نام ژورنی در هر حساب یکتاست. ساختی که با نام موجود برخورد کند `409` می‌گیرد و
خود نام در پیام می‌آید، چون راه چاره همان است: یا شناسه‌ی همان ژورنی را بدهید تا
پیش‌نویسش جایگزین شود، یا نام دیگری بگذارید. تا پیش از این جدا شدن، جوابش `503`
بود که یعنی «سرویس بالا نیست»، و کلاینتی که روی آن دوباره تلاش کند تا ابد همان
را می‌گیرد، در حالی که ژورنی موردنظرش تمام مدت وجود داشته.

### GET /v1/journeys/{id}/draft {#journeys-draft}

مجوز `journey.read`، هزینه ۱. پیش‌نویس ذخیره‌شده به‌علاوه دو فهرست:

```json
{
  "name": "خوش‌آمد",
  "graph": { "entry_id": "n1", "nodes": [] },
  "problems": [],
  "warnings": ["این سناریو با «بازگشت مشتری» مخاطب مشترک دارد"]
}
```

تفاوت `problems` و `warnings` مهم است: اولی جلوی انتشار را می‌گیرد و دومی
نمی‌گیرد. هشدار هم‌پوشانی مخاطب از میان سناریوهای در جریان همین حساب می‌آید و
همان چیزی است که پنل هم نشان می‌دهد.

### POST /v1/journeys/validate {#journeys-validate}

مجوز `journey.read`، هزینه ۱. گرافی را که هنوز ذخیره نکرده‌اید می‌سنجد. چیزی
ذخیره نمی‌شود و چیزی عوض نمی‌شود.

```json
{ "valid": false, "problems": ["گره n2 قالبی ندارد"], "warnings": [] }
```

**همیشه `200` است، حتی وقتی گراف بی‌اعتبار است.** درخواست موفق بوده؛ چیزی که
حکم دارد گراف است. اگر این مسیر برای گراف بد `422` می‌داد، «این گراف قابل قبول
نیست» از «سرور درخواستم را رد کرد» قابل تشخیص نبود.

### POST /v1/journeys/{id}/publish {#journeys-publish}

مجوز `journey.publish`، هزینه ۱. نسخه تازه را منتشر می‌کند و شماره‌اش را
برمی‌گرداند.

```json
{ "version": 4, "status": "active" }
```

> این کار برگشت ندارد. از همین لحظه هر کسی که ماشه سناریو را بزند وارد آن
> می‌شود. پیش از این فراخوان، گراف را با `POST /v1/journeys/validate` بسنجید.

اگر پیش‌نویس آماده نباشد، `422` با کد `not_publishable` و همان فهرست
`details.problems` را می‌گیرید، چون آن یک خرابی سرور نیست، خود گراف است.

### POST /v1/journeys/{id}/{action} {#journeys-action}

مجوز `journey.write`، هزینه ۱. سه کار مجاز است و بس:

| کار | وضعیت بعدش | معنی |
|---|---|---|
| `pause` | `paused` | ورود تازه متوقف می‌شود. کسانی که داخل‌اند سر جایشان می‌مانند |
| `resume` | `active` | ورود از سر گرفته می‌شود |
| `archive` | `archived` | از فهرست کار روزمره بیرون می‌رود |

```json
{ "status": "paused" }
```

هر چیز دیگری `400` با کد `unknown_action` می‌گیرد و فهرست کارهای مجاز در
`details.allowed` می‌آید، چون «کار نامعلوم» می‌گوید اشتباه کردید و نمی‌گوید چه
کار کنید.

## خروجی {#exports}

دو مسیر، هر دو `data.export` می‌خواهند و هر دو **پشت قفل نرم**اند. `data.export` از هر مجوز خواندنی جداست چون خروجی از ساختمان بیرون می‌رود.

### GET /v1/exports {#exports-list}

هزینه ۱. تنها مسیری روی این میزبان که پاکت صفحه را استفاده می‌کند و تنها مسیری که `?limit=` را می‌خواند. `?cursor=` خوانده و دور ریخته می‌شود.

```bash
curl -s "https://api.segmentic.net/v1/exports?limit=50" \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "data": [
    {
      "id": 42,
      "kind": "events",
      "format": "ndjson",
      "spec": { "segment_id": 12 },
      "status": "queued",
      "rows_written": 0,
      "bytes": 0,
      "attempts": 1,
      "expires_at": "2026-08-13T09:00:00Z",
      "requested_by": "api-key:3",
      "created_at": "2026-08-06T09:00:00Z"
    }
  ],
  "has_more": false
}
```

`status` یکی از `queued`، `running`، `ready`، `failed` یا `expired` است. `attempts` منتشر می‌شود چون «شکست خورد» و «سه بار شکست خورد و ایستاد» دو جواب متفاوت به تنها پرسشی هستند که مشتری درباره یک خروجی می‌پرسد.

`has_more` همیشه `false` است، حتی وقتی سطر بیشتری هست. `data` همیشه آرایه است.

شکست: `503` با کد `export_unavailable` و پیام `could not read the export list`.

> [!danger]
> **`GET /v1/exports/{id}` و مسیر دانلود روی این میزبان وجود ندارند.** یک یکپارچه‌سازی می‌تواند خروجی را در صف بگذارد و فهرست را ببیند، و بعد یک آدم باید فایل را از پنل بردارد. `location` روی سیم هست ولی یک مسیر ذخیره‌سازی است، نه لینک امضاشده.

### POST /v1/exports {#exports-create}

هزینه **۲۵**.

```bash
curl -s -X POST https://api.segmentic.net/v1/exports \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "events",
    "format": "ndjson",
    "spec": { "segment_id": 12, "from": "2026-07-01", "to": "2026-08-01" }
  }'
```

```json
{
  "id": 42,
  "status": "queued",
  "kind": "events",
  "expires_after_hours": 168
}
```

وضعیت `202`، چون فایل هنوز وجود ندارد. کسی که نود روز رویداد می‌خواهد چیزی می‌خواهد که چند دقیقه طول می‌کشد، و درخواستی که چند دقیقه اتصال را باز نگه دارد به مهلت load balancer می‌خورد.

| فیلد | لازم | قاعده |
|---|---|---|
| `kind` | بله | یکی از `events`، `messages`، `profiles`، `segment` |
| `format` | خیر | **هر چیزی که دقیقا `csv` نباشد `ndjson` می‌شود**، از جمله یک غلط املایی و از جمله فرمت‌های ناشناس |
| `spec` | خیر | دست‌نخورده و **بدون اعتبارسنجی** پاس داده می‌شود |

NDJSON پیش‌فرض است چون خروجی رویدادها با ویژگی‌های تودرتو یک مستطیل نیست، و صاف‌کردنش در CSV بی‌صدا تودرتویی را از بین می‌برد.

> [!warn]
> شکل `spec` برای هر `kind` فرق می‌کند، کد هیچ‌چیزی درونش را اعتبارسنجی نمی‌کند، و هیچ سندی امروز کلیدهای مجازش را برای هر `kind` فهرست نمی‌کند. یعنی `spec` غلط، خطا نمی‌گیرد؛ فایلی می‌سازد که آنچه انتظار داشتید نیست. تا وقتی این مستند شود، یک خروجی کوچک بگیرید و فایلش را ببینید.

`expires_after_hours` برابر ۱۶۸ است، یعنی هفت روز. فایلی که ایمیل همه مشتری‌ها را دارد و برای همیشه روی یک share می‌ماند، همان چیزی است که یک خروجی بی‌احتیاط را به یک نشت تبدیل می‌کند، و کسی یادش نمی‌ماند پاکش کند، پس سکو پاکش می‌کند.

`requested_by` به‌صورت `api-key:3` ثبت می‌شود. «چه کسی نشانی همه مشتری‌ها را خروجی گرفت» پرسشی است که ممیزی بعدا می‌پرسد، و جوابش باید چیزی را نام ببرد که قابل ابطال باشد.

| وضعیت | کد | چه وقت |
|---|---|---|
| `400` | `malformed_json` | بدنه JSON معتبر نیست |
| `422` | `export_kind_invalid` | پیام: `kind must be one of events, messages, profiles, segment` |
| `503` | `export_unavailable` | صف نتوانست بپذیرد |

---

## گزارش {#reports}

دو مسیر، هر دو `analytics.read` می‌خواهند، هزینه هرکدام **۲۵**، هر دو **پشت قفل نرم**. سقف بدنه ۱ مگابایت و مهلت **۴۵ ثانیه** (نه `query_timeout_sec`).

خطاهای این دو مسیر در پاکت پنل و به فارسی‌اند، با کد `invalid_report`. جزئیات در [پاکت خطا](/docs/api/management#errors).

### POST /v1/reports/funnel {#reports-funnel}

```bash
curl -s -X POST https://api.segmentic.net/v1/reports/funnel \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "steps": [
      { "name": "product_viewed" },
      { "name": "checkout_started" },
      { "name": "order_completed", "filters": [{ "prop": "revenue", "op": "gte", "value": "500000" }] }
    ],
    "range": { "from": "2026-05-01T00:00:00Z", "to": "2026-06-01T00:00:00Z" },
    "window": "7d",
    "split_by": "city"
  }'
```

```json
{
  "steps": [
    { "index": 0, "name": "product_viewed", "label": "product_viewed", "users": 700, "from_start": 1, "from_previous": 1, "dropped_here": 0 },
    { "index": 1, "name": "checkout_started", "label": "checkout_started", "users": 300, "from_start": 0.4286, "from_previous": 0.4286, "dropped_here": 400 }
  ],
  "buckets": [
    { "value": "تهران", "steps": [], "entered": 400, "completed": 180, "conversion": 0.45 }
  ],
  "entered": 700,
  "completed": 300,
  "conversion": 0.4286,
  "description": "..."
}
```

| فیلد | لازم | قاعده |
|---|---|---|
| `steps` | بله | بین ۲ و ۱۲ مرحله |
| `steps[].name` | بله | نام رویداد، خالی نباشد، حداکثر ۲۵۶ نویسه |
| `steps[].label` | خیر | برچسب نمودار |
| `steps[].filters` | خیر | حداکثر ۱۰ فیلتر برای هر مرحله |
| `steps[].filters[].op` | بله | متنی: `eq`، `ne`، `contains`، `prefix`. عددی: `gt`، `gte`، `lt`، `lte`، `num_eq`، `num_ne` |
| `range.from` و `range.to` | بله | RFC3339، `from` کوچک‌تر از `to`، بازه حداکثر ۷۳۰ روز |
| `window` | **بله، و باید بزرگ‌تر از صفر باشد** | مثل `"7d"`، `"1.5d"`، `"36h"`. نباید از خود بازه بزرگ‌تر باشد |
| `strict` | خیر | پیش‌فرض `false` |
| `split_by` | خیر | از فهرست پایین، یا `prop:<کلید>` |

`window` را فراموش نکنید. قیف پرداخت روی سی روز و همان قیف روی یک ساعت، دو پرسش متفاوت‌اند و جواب بدون آن بی‌معنی است. اگر ندهید `400` می‌گیرید.

فهرست مجاز `split_by`: `platform`، `os`، `device`، `app_version`، `country`، `city`، `region`، `province`، `utm_source`، `utm_campaign`، `browser`. به‌علاوه شکل `prop:` برای هر کلید ویژگی رویداد، مثل `prop:category`.

نرخ‌ها کسرند، نه درصد. `buckets` فقط وقتی می‌آید که تفکیک خواسته شده باشد.

### POST /v1/reports/retention {#reports-retention}

```bash
curl -s -X POST https://api.segmentic.net/v1/reports/retention \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "start": { "name": "signed_up" },
    "return": { "name": "order_completed" },
    "range": { "from": "2026-01-01T00:00:00Z", "to": "2026-07-01T00:00:00Z" },
    "granularity": "week",
    "periods": 12
  }'
```

```json
{
  "granularity": "week",
  "period_label": "هفته",
  "cohorts": [
    {
      "cohort": "1405-02-11",
      "label": "...",
      "size": 4021,
      "cells": [
        { "period": 0, "users": 4021, "rate": 1, "observable": true },
        { "period": 1, "users": 1802, "rate": 0.448, "observable": true },
        { "period": 8, "users": 0, "rate": 0, "observable": false }
      ]
    }
  ],
  "average": [{ "period": 0, "users": 0, "rate": 1, "observable": true }],
  "description": "..."
}
```

| فیلد | لازم | پیش‌فرض | قاعده |
|---|---|---|---|
| `start` | خیر | خالی | نام خالی یعنی «هر فعالیتی» |
| `return` | خیر | خالی | همان |
| `range` | بله | ندارد | بازه حداکثر ۷۳۰ روز |
| `granularity` | خیر | `day` | یکی از `day`، `week`، `month` |
| `periods` | خیر | ۳۰ | حداکثر ۶۰ |

دو مرحله جدا هستند چون «برگشت» به‌ندرت یعنی «دوباره همان کار». یک اپ فروشگاهی به این اهمیت می‌دهد که چه کسی ثبت‌نام کرد و بعد **خرید کرد**؛ پرسیدن اینکه اپ را دوباره باز کرد یا نه، عدد را زیبا می‌کند و هیچ‌چیز را جواب نمی‌دهد.

`observable: false` یعنی گزارش هنوز آن‌قدر عمر نکرده که بداند. کوهورتی که دیروز شروع شده عدد روز سی‌ام ندارد، و نمایش‌دادن آن به‌صورت صفر همان‌جایی است که یک محصول سالم شبیه محصولی می‌شود که دارد می‌میرد.

مرزهای کوهورت در Go و به وقت تهران و روی تقویم ایرانی حساب می‌شوند. توابع تقویمی ClickHouse برای این بازار درست نیستند: `toStartOfMonth` میلادی است و `toStartOfWeek` نمی‌تواند شنبه شروع کند.

منحنی `average` وزنی است، یعنی مجموع برگشتی‌ها تقسیم بر مجموع شروع‌کننده‌ها، نه میانگین درصدها. میانگین درصدها به کوهورتی چهار نفره که همه برگشتند اجازه می‌داد منحنی را به‌اندازه کوهورتی چهل‌هزار نفره بالا بکشد.

---

## POST /v1/messages {#messages}

مجوز `campaign.send`، هزینه ۱. فقط وقتی `features.transactional` روشن باشد ثبت می‌شود. سقف بدنه **۲۵۶ کیلوبایت**.

هزینه‌اش در واحد کوئری ناچیز و در پیامد عظیم است. چیزی که قرار بود آن را کران‌دار کند بودجه درخواست نیست، بلکه محدودکننده نرخ به‌ازای حساب است، و آن محدودکننده **پیش‌فرض خاموش است**. یعنی روی یک نصب بدون تنظیم، تنها چیزی که جلوی این مسیر را می‌گیرد همان بودجه ۶۰۰ واحدی در دقیقه است: یک کلید با `campaign.send` می‌تواند در دقیقه ششصد پیام بفرستد. اگر این عدد برای شما زیاد است، سقف حساب را تنظیم کنید. رجوع کنید به [دو محدودکننده روی یک مسیر](/docs/api/management#messages-limits).

```bash
curl -s -X POST https://api.segmentic.net/v1/messages \
  -H "Authorization: Bearer sk_seg_..." \
  -H "Content-Type: application/json" \
  -d '{
    "user_id": "u_9137",
    "channel": "sms",
    "category": "transactional",
    "template_id": 42,
    "vars": { "code": "8391" },
    "idempotency_key": "order-8821-shipped"
  }'
```

```json
{
  "message_id": "t7.order-8821-shipped",
  "status": "sent",
  "sent_at": "2026-08-06T09:12:41Z"
}
```

وضعیت `200` هم برای ارسال تازه و هم برای پخش دوباره.

| فیلد | لازم | قاعده |
|---|---|---|
| `user_id` | بله | خالی نباشد |
| `channel` | بله | یکی از `push`، `sms`، `email`، `webpush`، `inapp`، `bale`، `eitaa`، `rubika` |
| `category` | خیر | `transactional` (پیش‌فرض) یا `critical`. `marketing` **رد می‌شود** |
| `template_id` | بله | ناصفر. **متن درون‌خطی اصلا پذیرفته نمی‌شود** |
| `vars` | خیر | حداکثر ۴۰ کلید |
| `idempotency_key` | بله | الگوی `^[A-Za-z0-9._:-]{8,200}$` |

> [!warn]
> اینجا `web` کار **نمی‌کند**، با اینکه در ساخت کمپین کار می‌کند و بعضی ابزارها آن را تبلیغ می‌کنند. این مسیر رشته را مستقیم با فهرست بالا مقایسه می‌کند و جدول مستعارها روی این راه نیست. `webpush` بنویسید. `messenger` و `webhook` هم اینجا رد می‌شوند؛ فقط برای کمپین‌اند.

**این تنها مسیری روی این میزبان است که فیلد ناشناس را رد می‌کند.** کسی که `idempotency_key` را غلط تایپ کرده، وگرنه در هر تلاش دوباره یک کلید کاملا تازه می‌گرفت و به‌ازای هر تلاش یک پیام می‌فرستاد، یعنی دقیقا همان شکستی که این مسیر برای جلوگیری از آن ساخته شده، از راه یک غلط تایپی.

رد `marketing` تزیینی نیست. این مسیر از سقف‌های تکرار و ساعت‌های سکوت رد می‌شود، پس پذیرفتن یک پیام بازاریابی اینجا به هر مشتری یک راه مستند برای دور زدن قواعد ارسال خودش می‌داد، و اولین باری که مهم می‌شد یک پیامک تبلیغاتی ساعت سه بامداد به کل یک فهرست بود.

### کلید یکتایی {#messages-idempotency}

`idempotency_key` می‌تواند در بدنه بیاید یا به‌صورت هدر `Idempotency-Key`. اگر هر دو باشند، بدنه برنده است. بزرگی و کوچکی حروف حفظ می‌شود: یکی‌کردنشان `Order-8821` را با `order-8821` ادغام می‌کرد، دو کلید که یک تماس‌گیرنده سخت‌گیر ممکن است واقعا برای دو چیز متفاوت استفاده کند.

`message_id` ساخته نمی‌شود، مشتق می‌شود: `t` به‌علاوه شناسه حساب، نقطه، کلید شما.

| حالت | جواب |
|---|---|
| بار اول | `200` با نتیجه |
| تکرار **پس از** تمام‌شدن اولی | `200` با همان بدنه ذخیره‌شده، به‌علاوه `"replayed": true` |
| تکرار **حین اجرای** اولی | `409` با `Retry-After: 1` و بدنه `{"error":"transactional: a message with this idempotency key is already in flight"}` |
| تکرار پس از **شکست** ارسال اولی | اجازه اجرا دارد. رزرو آزاد شده است |

`replayed` به تماس‌گیرنده اجازه می‌دهد «قبلا این کار را کردیم» را از «همین الان این کار را کردیم» تشخیص بدهد، که وقتی تلاش اول time out شده و نمی‌دانید کدامش اتفاق افتاده، اهمیت دارد.

> [!danger]
> **کلیدهای یکتایی هیچ‌وقت جارو نمی‌شوند.** یک مقدار پیکربندی به نام «نگه‌داری هفت روزه» وجود دارد و هیچ کدی آن را نمی‌خواند و هیچ فرمانی جارو را صدا نمی‌زند. دو نتیجه عملی: کلیدی مثل `order-8821-shipped` یک سال بعد هم پخش دوباره همان نتیجه یک‌ساله را می‌دهد، نه یک ارسال تازه. و رزروی که یک پردازه crash‌کرده رها کرده، هیچ‌وقت خودکار پاک نمی‌شود، پس هر تلاش دوباره روی آن کلید تا ابد `409` می‌گیرد تا وقتی یک نفر دستی سطرش را پاک کند. کلیدهایتان را یکتا و برای همیشه یکتا انتخاب کنید.

### دو محدودکننده روی یک مسیر {#messages-limits}

این مسیر دوبار متر می‌شود و دو رد متفاوت با دو بدنه متفاوت دارد:

- بودجه درخواست، یک واحد، به‌ازای هر کلید. رد: `429` با کد `budget_exhausted` در پاکت این API.
- محدودکننده نرخ پیام، یک درخواست، به‌ازای هر حساب در هر دقیقه تقویمی. رد: `429` با `Retry-After: 60` و بدنه مسطح `{"error":"rate limit exceeded: N requests per minute"}`.

سقف دومی از ستون `api_rate_per_minute` همان حساب می‌آید و اگر خالی باشد از پیکربندی سرور، که **پیش‌فرضش صفر است**. صفر یعنی این محدودکننده کامل خاموش است، که برای نصب تک‌مشتری درست است، و روی چنین نصبی هدرهای نرخ هم فرستاده نمی‌شوند. این یکی **باز** شکست می‌خورد: خطای جست‌وجو ارسال را اجازه می‌دهد و هشدار لاگ می‌کند.

وقتی سقفی تنظیم شده باشد، دو هدر `X-RateLimit-Limit` و `X-RateLimit-Remaining` روی **هر** پاسخ این مسیر می‌آیند، نه فقط روی ردها. تماس‌گیرنده‌ای که نمی‌تواند ببیند چقدر به سقف نزدیک است، راهی برای آهسته‌شدن پیش از رد شدن ندارد.

### خطاها {#messages-errors}

همه در پاکت مسطح، نه پاکت این API:

| وضعیت | بدنه |
|---|---|
| `400` | `{"error":"malformed JSON: <detail>"}` برای JSON بد یا فیلد ناشناس |
| `400` | `{"error":"transactional: user_id is required"}` و خواهرهایش |
| `409` | `{"error":"transactional: a message with this idempotency key is already in flight"}` |
| `429` | `{"error":"rate limit exceeded: N requests per minute"}` |
| `503` | `{"error":"message not sent"}` |
| `200` | نتیجه، حتی وقتی دفتر ثبت پس از ارسال شکست خورده. پیام واقعا رفته و تماس‌گیرنده‌ای که خلافش را بشنود دومی را می‌فرستد |

متن‌های دقیق خطای تماس‌گیرنده: `transactional: user_id is required`، `transactional: template_id is required`، `transactional: idempotency_key is required`، `transactional: unknown channel`، `transactional: this endpoint does not send marketing; use a campaign`، `transactional: too many variables`، و پیام کلید بدشکل که بازه ۸ تا ۲۰۰ نویسه را نام می‌برد.

**هیچ مسیری برای خواندن وضعیت یک پیام وجود ندارد.** `GET /v1/messages/{idempotency_key}` ساخته نشده است. تنها راه دیدن نتیجه یک ارسال، پاسخ همان تماس یا گزارش پیام‌ها در پنل است.

---

## پاکت خطا {#errors}

شکل پاکت این است:

```json
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry data.export, see GET /v1/whoami for what it does carry",
    "details": { "limit": 500, "sent": 501 },
    "need": "data.export"
  }
}
```

`code` پایدار و ماشین‌خوان است. **قرارداد همان است؛ `message` نیست.** یکپارچه‌سازی‌ای که روی متن پیام شاخه بزند، اولین باری که ما جمله را بهتر کنیم می‌شکند. `details` وقتی هست که مشکل فیلدی خاص باشد، و `need` فقط روی `403` می‌آید.

مسیری که وجود ندارد این را می‌گیرد:

```json
{
  "error": {
    "code": "unknown_endpoint",
    "message": "no such endpoint: POST /v1/team/keys, see GET /v1/capabilities"
  }
}
```

### پاکت یک شکل ندارد {#errors-not-uniform}

این مهم‌ترین جمله این صفحه برای کسی است که مدیریت خطا می‌نویسد.

یازده تا از بیست‌ودو مسیر، هندلر مشترک با پنل دارند، و آن هندلرها در پاکت پنل جواب می‌دهند: `{"error":"<رشته>"}` یا `{"error":"<رشته>","code":"..."}`. یعنی `error` گاهی شیء است و گاهی رشته.

| مسیر | چه شکستی | بدنه واقعی |
|---|---|---|
| `GET /v1/schema/events` | `503` | `{"error":"schema unavailable"}` |
| `GET /v1/schema/traits` | `503` | `{"error":"schema unavailable"}` |
| `POST /v1/audiences/validate` | `400` JSON بد | `{"error":"malformed JSON"}` |
| `POST /v1/audiences/count` | `400` و `503` | `{"error":"segment: ..."}` و `{"error":"count unavailable"}` |
| `GET /v1/segments` | `503` | `{"error":"segments unavailable"}` |
| `GET /v1/segments/{id}` | `400` و `404` | `{"error":"invalid segment id"}` و `{"error":"segment not found"}` |
| `GET /v1/campaigns` | `503` | `{"error":"campaigns unavailable"}` |
| `GET /v1/campaigns/{id}` | `400` و `404` | `{"error":"invalid campaign id"}` و `{"error":"campaign not found"}` |
| `POST /v1/reports/funnel` | `400` و `503` | `{"error":"<فارسی>","code":"invalid_report"}` و `{"error":"<فارسی>"}` |
| `POST /v1/reports/retention` | `400` و `503` | همان |
| `POST /v1/messages` | هر شکستی | `{"error":"<رشته>"}` مسطح |

به‌علاوه چهار رد که پیش از اجرای هندلر تولید می‌شوند و همه در پاکت مسطح‌اند: `wrong_surface` روی `401`، و `ip_not_allowed`، `impersonation_read_only` و `impersonation_forbidden` روی `403`.

پس مدافعانه parse کنید. اول نوع `error` را ببینید: اگر شیء است، `error.code` را بخوانید؛ اگر رشته است، آن را به‌عنوان پیام لاگ کنید و روی status تصمیم بگیرید.

> [!warn]
> **متن این خطاها همیشه فارسی است.** میان‌افزار زبان روی این شنونده نصب نشده، پس `Accept-Language` روی این میزبان اصلا خوانده نمی‌شود. هدر `Accept-Language: en` هیچ اثری ندارد. کلاینتی که متن خطا را لاگ می‌کند باید UTF-8 و راست‌به‌چپ را تحمل کند.

### همه کدها {#errors-codes}

| کد | وضعیت | یعنی |
|---|---|---|
| `unauthenticated` | `401` | اعتبارنامه نیست یا resolve نشد |
| `api_key_required` | `401` | نشست فرستاده شده |
| `write_key_rejected` | `401` | توکن `wk_` فرستاده شده |
| `key_expired` | `401` | کلید منقضی شده |
| `forbidden` | `403` | مجوز نیست. `need` را بخوانید |
| `account_locked` | `403` | قفل نرم. `details.reason` را بخوانید |
| `budget_exhausted` | `429` | بودجه دقیقه تمام شده. صبر کنید و دوباره بزنید |
| `budget_unavailable` | `503` | شمارنده بودجه در دسترس نیست. گذرا |
| `unknown_endpoint` | `404` | مسیر یا متد اشتباه |
| `malformed_json` | `400` | بدنه JSON نیست |
| `bad_id` | `400` | شناسه مسیر عدد مثبت نیست |
| `not_found` | `404` | سگمنت یا کمپین وجود ندارد |
| `filter_invalid` | `422` | تعریف سگمنت رد شد |
| `name_required` | `400` | نام سگمنت خالی است |
| `segment_unavailable` | `503` | store سگمنت شکست خورد |
| `invalid_channel` | `400` | کانال کمپین resolve نشد |
| `campaign_invalid` | `422` | اعتبارسنجی کمپین رد شد |
| `campaign_unavailable` | `503` | store کمپین شکست خورد |
| `not_submittable` | `409` | کمپین `draft` یا `paused` نیست |
| `approval_required` | `409` | تایید لازم است و نیست |
| `approval_stale` | `409` | کمپین بعد از تایید عوض شده |
| `approval_unavailable` | `503` | خواندن یا نوشتن تایید شکست خورد |
| `export_kind_invalid` | `422` | `kind` در فهرست نیست |
| `export_unavailable` | `503` | صف خروجی شکست خورد |
| `batch_empty` | `400` | آرایه `events` خالی است |
| `batch_too_large` | `413` | بیش از ۵۰۰ رویداد |
| `all_events_rejected` | `422` | هیچ رویدادی قابل قبول نبود |
| `ingest_unavailable` | `503` | صف ورود داده در دسترس نیست |
| `quota_cancelled` | `402` | اشتراک سرویس نمی‌دهد |
| `quota_trial_over` | `402` | دوره آزمایشی تمام شده |
| `quota_event_cap` | `402` | سقف سخت رویداد |
| `quota_message_cap` | `402` | تعریف شده و هرگز برگردانده نمی‌شود. تنها `402` اینجا مال `POST /v1/events` است و آن سهمیه را با سنجه رویداد می‌پرسد؛ سقف پیام فقط برای سنجه پیام خوانده می‌شود |

خانواده `quota_*` را **تکرار نکنید**: تا کسی پرداخت نکند یا دوره عوض نشود چیزی تغییر نمی‌کند. صفحه [کدهای خطا](/docs/errors) همین فهرست را با کار پیشنهادی برای هرکدام دارد.

---

## چیزهایی که این API ندارد {#absent}

هر سطر این فهرست، چیزی است که یک نفر معقول انتظارش را دارد و امروز وجود ندارد. صادقانه نوشتنش از یک جمله محتمل ارزان‌تر است.

- **صفحه‌بندی کارآمد.** `?cursor=` هیچ‌جا خوانده نمی‌شود و هیچ پاسخی کلید `next_cursor` ندارد.
- **`PATCH` روی هیچ‌چیز.** فقط `PUT` با جایگزینی کامل، بدون `If-Match` و بدون نشانه نسخه.
- **یکتایی روی هر مسیری جز `POST /v1/messages`.** تلاش دوباره روی `POST /v1/segments` که time out شده، سگمنت دوم می‌سازد.
- **مکث، ادامه یا لغو کمپین.**
- **تایید کمپین، صف تایید، تاریخچه تایید.**
- **دانلود خروجی، و `GET /v1/exports/{id}`.**
- **خواندن وضعیت یک پیام تراکنشی.**
- **خواندن یا نوشتن پرونده یک کاربر.** `GET /v1/profiles/{user_id}` وجود ندارد.
- **رضایت و لغو اشتراک از راه API.**
- **الگو، سناریو، ممیزی و قوانین ارسال.** همه فقط در پنل.
- **پیش‌نمایش سگمنت، اندازه سگمنت، و تخمین نمونه‌گیری‌شده.**
- **گزارش مسیر.** `POST /v1/reports/paths` روی این میزبان ثبت نشده است.
- **ساختن کلید باریک (scoped).** ستون در دیتابیس هست و هیچ راهی برای نوشتنش نیست.
- **بودجه گیرنده و سقف سطر PII به‌ازای کلید.** ستون‌هایشان در دیتابیس هستند و هیچ کدی آن‌ها را نمی‌خواند.
- **هدر باقی‌مانده بودجه.**
- **هشدار پیش از قفل نرم.** وضعیت «معوق» روز سی‌ویکم روی این سطح دیده نمی‌شود.
- **preflight برای مرورگر.** این API برای تماس سرور به سرور است.

اگر یکی از این‌ها را لازم دارید، امروز راهش پنل است. برای اینکه بدانید چه چیزی بدون خبر عوض می‌شود و چه چیزی نه، [نسخه‌بندی API و تغییرها](/docs/versioning) را بخوانید.

---

# کدهای خطا و کاری که باید کرد

> هر کدی که ممکن است برگردد، معنی‌اش، و اینکه باید دوباره تلاش کنید، چیزی را درست کنید یا صبر کنید.
> https://segmentic.net/docs/errors

خطا در سگمنتیک یک شکل ندارد. میزبان ورود داده یک شکل دارد، میزبان مدیریتی شکل دیگری، و شکل سومی روی یازده مسیر از میزبان مدیریتی بیرون می‌زند. کلاینتی که فقط یکی از این سه را می‌شناسد، روی دو تای دیگر `undefined` می‌خواند و شاخه‌ای را می‌رود که هیچ‌کس تستش نکرده است.

این صفحه هر کدی را که یک سطح مشتری‌رو می‌تواند برگرداند فهرست می‌کند، دسته‌بندی‌شده بر اساس کاری که باید بکنید.

## سه شکل خطا، نه یکی {#envelopes}

محتاطانه پارس کنید. اگر `error` یک آبجکت است، `error.code` را بخوانید. اگر `error` یک رشته است، هیچ کدی وجود ندارد و تنها چیزی که دارید وضعیت HTTP است. اگر کلید `error` وجود ندارد، روی میزبان ورود داده هستید و فیلدی که می‌خواهید `status` است.

### میزبان ورود داده {#ingest-envelope}

نشانی `https://in.segmentic.net` یک آبجکت تخت برمی‌گرداند. هیچ فیلد `code` روی این میزبان وجود ندارد. مقدار ماشین‌خوان `status` است و همیشه یکی از دو رشته `"ok"` یا `"error"`.

```json title="POST /v1/track, 400"
{"status":"error","message":"malformed JSON"}
```

پاسخ موفق شمارش دارد. `accepted` و `rejected` وقتی صفرند فرستاده نمی‌شوند، نه اینکه `0` بیایند. پس دسته‌ای که کامل رد شده باشد در اصل کلید `accepted` ندارد.

```json title="POST /v1/track, 200"
{
  "status": "ok",
  "accepted": 1,
  "warnings": [
    {
      "code": "generated_message_id",
      "field": "message_id",
      "note": "no message_id sent; retries of this event cannot be de-duplicated"
    }
  ]
}
```

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

```json title="POST /v1/batch, 200"
{
  "status": "ok",
  "accepted": 2,
  "rejected": 1,
  "errors": [{"index": 1, "reason": "unknown_type: \"trak\""}]
}
```

رشته `reason` متن کامل خطاست و مقدار خودتان را هم در خود دارد. بخش پایدارش کلمه اول است، اینجا `unknown_type`. روی پیشوند تطبیق بدهید، نه روی کل رشته.

نوع محتوا روی هر پاسخ این میزبان `application/json; charset=utf-8` است.

سه مسیر ورود داده از این شکل استفاده نمی‌کنند. `POST /v1/bounce/{local}` متن ساده برمی‌گرداند نه JSON. `POST /v1/inbox` و `POST /v1/devices` شکل خودشان را دارند که در [دستگاه‌ها و پوش](/docs/devices) و [درون‌سایتی و صندوق پیام](/docs/onsite) آمده است. `GET /s/{code}` برای کد ناشناخته یک ۴۰۴ متن ساده و در حالت درست ۳۰۲ می‌دهد.

### میزبان مدیریتی {#management-envelope}

نشانی `https://api.segmentic.net` همه‌چیز را زیر `error` تودرتو می‌کند.

```json title="POST /v1/reports/funnel, 429"
{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```

فیلد `details` بخش ساختاریافته خطای اعتبارسنجی را می‌آورد، تا بتوانید انگشت بگذارید روی همان جای payload خودتان که مشکل دارد.

```json title="POST /v1/events, 413"
{
  "error": {
    "code": "batch_too_large",
    "message": "a batch may carry at most 500 events",
    "details": {"limit": 500, "sent": 501}
  }
}
```

فیلد `need` روی ۴۰۳ می‌آید و درست نام همان مجوزی را می‌گوید که کلید ندارد. به‌عمد منتشر شده است: راه دیگر این بود که مشتری تیکت بزند تا بفهمد کدام مجوز را باید بدهد.

```json title="POST /v1/campaigns/41/send, 403"
{
  "error": {
    "code": "forbidden",
    "message": "this key does not carry campaign.send, see GET /v1/whoami for what it does carry",
    "need": "campaign.send"
  }
}
```

روی `POST /v1/events`، مقدار `details` به‌جای آبجکت، آرایه قلم‌به‌قلم است.

```json title="POST /v1/events, 422"
{
  "error": {
    "code": "all_events_rejected",
    "message": "no event in this batch could be accepted",
    "details": [
      {"index": 0, "reason": "missing_identity"},
      {"index": 1, "reason": "missing_identity"}
    ]
  }
}
```

### شکل تخت، روی یازده مسیر {#flat-envelope}

یازده مسیر مدیریتی هندلرشان را با پنل به اشتراک می‌گذارند، و نویسنده خطای پنل یک رشته تخت بدون هیچ کدی می‌نویسد.

```json title="GET /v1/segments/7, 404"
{"error":"segment not found"}
```

کلاینتی که فقط `body.error.code` را می‌خواند، روی تک‌تک این‌ها `undefined` می‌گیرد. مسیرها و خرابی‌هایی که این شکل را دارند:

| مسیر | خرابی‌های تخت |
|---|---|
| `GET /v1/schema/events` | ۵۰۳ با `schema unavailable` |
| `GET /v1/schema/traits` | ۵۰۳ با `schema unavailable` |
| `POST /v1/audiences/validate` | ۴۰۰ با `malformed JSON` |
| `POST /v1/audiences/count` | ۴۰۰ با `malformed JSON`، ۴۰۰ با متن خود کامپایلر، ۵۰۳ با `count unavailable` |
| `GET /v1/segments` | ۵۰۳ با `segments unavailable` |
| `GET /v1/segments/{id}` | ۴۰۰ با `invalid segment id`، ۴۰۴ با `segment not found` |
| `GET /v1/campaigns` | ۵۰۳ با `campaigns unavailable` |
| `GET /v1/campaigns/{id}` | ۴۰۰ با `invalid campaign id`، ۴۰۴ با `campaign not found` |
| `POST /v1/reports/funnel` | ۴۰۰ با `invalid_report`، ۵۰۳ با متن فارسی |
| `POST /v1/reports/retention` | ۴۰۰ با `invalid_report`، ۵۰۳ با متن فارسی |
| `POST /v1/messages` | ۴۰۰ و ۴۰۹ و ۴۲۹ و ۵۰۳، همه‌شان |

کد `invalid_report` استثنای داخل استثناست: تخت است ولی کد دارد.

```json title="POST /v1/reports/funnel, 400"
{"error":"قیف به دست‌کم دو مرحله نیاز دارد","code":"invalid_report"}
```

چهار رد شدن از نگهبان مشترک اعتبارنامه هم با همین شکل تخت به میزبان مدیریتی می‌رسند، با کد و متن فارسی: `wrong_surface` روی ۴۰۱، و `impersonation_read_only` و `impersonation_forbidden` و `ip_not_allowed` روی ۴۰۳. آخری با یک کلید API معمولی هم دیده می‌شود، چون فهرست نشانی‌های مجاز حساب روی هر اعتبارنامه‌ای اعمال می‌شود، نه فقط روی نشست مرورگر.

> [!warn]
> نه میزبان ورود داده و نه میزبان مدیریتی به `Accept-Language` توجه نمی‌کنند. میان‌افزار زبان فقط روی خود پنل سوار است. هر پیامی که از کاتالوگ ترجمه بیاید، هرچه بخواهید، فارسی به دست شما می‌رسد. این شامل هر چهار پیام `quota_*` و پیام `account_locked` است.

## روی کد شاخه بزنید، هرگز روی متن {#code-not-message}

کد قرارداد است. متن قرارداد نیست و هر وقت جمله بهتری پیدا شود عوض می‌شود.

دو پیام این را ملموس می‌کنند. متن `write_key_rejected` دو بار نویسه سه‌نقطه `U+2026` دارد، داخل `(wk_…)` و `(sk_seg_…)`، نه سه نقطه جدا. متن قالب کلید تراکنشی بین ۸ و ۲۰۰ یک en dash با کد `U+2013` دارد، نه خط تیره ساده. یکپارچه‌سازی‌ای که روی هرکدام از این دو رشته تطبیق بدهد، با نویسه‌ای می‌شکند که نویسنده‌اش هرگز تایپش نکرده است.

## درخواست را درست کنید {#fix}

تلاش دوباره روی هرکدام از این‌ها یعنی فرستادن همان payload به همان رد شدن. شکل تودرتو، روی میزبان مدیریتی.

| کد | HTTP | مسیرها | یعنی چه |
|---|---|---|---|
| `malformed_json` | ۴۰۰ | هر مسیری که بدنه می‌گیرد | بدنه پارس نشد، یا بزرگ‌تر از `8 MiB` بود |
| `batch_empty` | ۴۰۰ | `POST /v1/events` | کلید `events` نبود یا خالی بود |
| `bad_id` | ۴۰۰ | `PUT /v1/segments/{id}`، `DELETE /v1/segments/{id}`، `POST /v1/campaigns/{id}/send`، `POST /v1/campaigns/{id}/submit` | مسیر شناسه عددی مثبت نداشت |
| `name_required` | ۴۰۰ | `POST /v1/segments` | فیلد `name` خالی یا فقط فاصله بود |
| `invalid_channel` | ۴۰۰ | `POST /v1/campaigns` | این کانال چیزی نیست که این حساب بتواند رویش بنویسد. مقدار `details` برابر `{"field":"channel"}` است |
| `unknown_endpoint` | ۴۰۴ | مسیر پیش‌فرض | این مسیر روی این میزبان ثبت نشده است. متن، متد و مسیر را می‌گوید و به `GET /v1/capabilities` ارجاع می‌دهد |
| `not_found` | ۴۰۴ | `PUT /v1/segments/{id}`، `POST /v1/campaigns/{id}/send`، `POST /v1/campaigns/{id}/submit` | شناسه ناشناخته است، یا مال حساب دیگری است. این دو حالت به‌عمد از هم جدا نشده‌اند |
| `batch_too_large` | ۴۱۳ | `POST /v1/events` | بیش از ۵۰۰ رویداد. مقدار `details` برابر `{"limit":500,"sent":N}` است. دسته را بشکنید |
| `filter_invalid` | ۴۲۲ | `POST /v1/audiences/validate`، `POST /v1/segments`، `PUT /v1/segments/{id}` | تعریف مخاطب کامپایل نشد. متن، جمله خود کامپایلر است |
| `campaign_invalid` | ۴۲۲ | `POST /v1/campaigns`، `POST /v1/campaigns/{id}/submit` | کمپین از اعتبارسنجی رد نشد |
| `export_kind_invalid` | ۴۲۲ | `POST /v1/exports` | مقدار `kind` یکی از `events` و `messages` و `profiles` و `segment` نبود |
| `all_events_rejected` | ۴۲۲ | `POST /v1/events` | هیچ رویدادی از دسته نرمال‌سازی نشد. مقدار `details` آرایه قلم‌به‌قلم است |

سه کد دیگر ۴۰۹ هستند و درست کردنشان کار آدم است، نه ویرایش payload.

| کد | HTTP | مسیر | یعنی چه |
|---|---|---|---|
| `approval_required` | ۴۰۹ | `POST /v1/campaigns/{id}/send` | این حساب تایید کمپین را قبل از ارسال لازم دارد. اول ثبتش کنید، بعد تایید بگیرید |
| `approval_stale` | ۴۰۹ | `POST /v1/campaigns/{id}/send` | کمپین بعد از تایید عوض شده است. دوباره ثبتش کنید |
| `not_submittable` | ۴۰۹ | `POST /v1/campaigns/{id}/submit` | فقط کمپین پیش‌نویس یا متوقف‌شده را می‌شود برای تایید ثبت کرد |

مسیر `POST /v1/audiences/validate` برای فیلتر نامعتبر ۴۲۲ می‌دهد، در حالی که نقطه معادلش در پنل ۲۰۰ با `valid: false` می‌دهد. پنل برای فرمی که کسی دارد در آن تایپ می‌کند درست است و برای یکپارچه‌سازی‌ای که خطایش روی وضعیت شاخه می‌زند غلط.

## اعتبارنامه را درست کنید {#credentials}

چهارتای این‌ها ۴۰۱ هستند و با هم فرق دارند. قبل از اینکه دنبال غلط تایپی بگردید کد را بخوانید.

| کد | HTTP | یعنی چه |
|---|---|---|
| `unauthenticated` | ۴۰۱ | هیچ اعتبارنامه‌ای روی درخواست نبود، یا بود و شناخته نشد. متن: `a valid API key is required` |
| `api_key_required` | ۴۰۱ | نشست پنل فرستاده شده است. این میزبان فقط کلید `sk_seg_` می‌گیرد |
| `write_key_rejected` | ۴۰۱ | توکن با `wk_` شروع می‌شود. آن کلید عمومی است و داخل اپ شما منتشر می‌شود و چیزی نمی‌تواند بخواند. یک کلید API بسازید |
| `key_expired` | ۴۰۱ | کلید شناخته شد و تاریخ انقضایش گذشته است. بچرخانیدش |
| `wrong_surface` | ۴۰۱ | اعتبارنامه ستادی روی میزبان مشتری، یا برعکس. شکل تخت، متن فارسی |
| `forbidden` | ۴۰۳ | نقش کلید در اشتراک با دامنه‌اش این مجوز را ندارد. فیلد `need` نامش را می‌گوید. مسیر `GET /v1/whoami` مجموعه مؤثر را برمی‌گرداند |
| `ip_not_allowed` | ۴۰۳ | حساب فهرست نشانی مجاز دارد و این نشانی در آن نیست. شکل تخت، متن فارسی |
| `impersonation_read_only` | ۴۰۳ | نشست پشتیبانی فقط‌خواندنی خواسته چیزی را تغییر بدهد. شکل تخت |
| `impersonation_forbidden` | ۴۰۳ | نشست پشتیبانی کاری را خواسته که پشتیبانی هرگز در حساب شما نمی‌تواند بکند. شکل تخت |

مسیر `GET /v1/whoami` یک واحد بودجه خرج می‌کند، مثل هر مسیر ساده دیگری، و مجموعه مؤثر مجوزها را برمی‌گرداند، پس یک کلاینت خوب می‌تواند موقع بالا آمدن شکست بخورد به‌جای اینکه روی همان یک فراخوانی ماهانه‌ای شکست بخورد که به مجوز نداشته‌اش نیاز دارد. تنها مسیر میزبان مدیریتی که سنجیده نمی‌شود `GET /v1/status` است.

```bash
curl -s https://api.segmentic.net/v1/whoami \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "tenant_id": 42,
  "api_key_id": 7,
  "role": "analyst",
  "permissions": [
    "analytics.read", "audit.read", "campaign.read", "data.export",
    "event.read", "journey.read", "member.read", "profile.read",
    "segment.read", "settings.read", "template.read"
  ],
  "scoped": false
}
```

فهرست `permissions` مجموعه مؤثر است و الفبایی مرتب می‌شود. نقش یکی از این هفت‌تاست: `owner` و `admin` و `marketer` و `analyst` و `viewer` و `approver` و `finance`. کلید با نقش `owner` اصلا ساخته نمی‌شود، پس هیچ کلیدی `tenant.transfer` و `tenant.delete` ندارد.

مقدار `scoped` روی هر کلیدی که این محصول می‌تواند بسازد `false` است. ستون دامنه در پایگاه داده هست و خوانده هم می‌شود، ولی هیچ مسیری آن را نمی‌نویسد، پس هر کلید کل نقشش را دارد و کلید باریک‌تر از نقش هنوز ساختنی نیست.

روی میزبان ورود داده، هر دو پیام `missing write key` و `invalid write key` وضعیت ۴۰۱ دارند و هر دو دائمی‌اند. دومی برای کلید ناشناخته و کلید باطل‌شده و کلید معلق یک جواب می‌دهد، تا نشود با این نقطه فهمید چه کلیدهایی وجود دارند.

## صبر کنید {#wait}

| کد | HTTP | کجا | چه کنید |
|---|---|---|---|
| `budget_exhausted` | ۴۲۹ | هر مسیر مدیریتی به‌جز `GET /v1/status` | هدر `Retry-After: 60`. سطل دقیقه با ساعت دیواری می‌چرخد. [سقف‌ها](/docs/limits#budget) را ببینید |
| بدون کد، `error` تخت | ۴۲۹ | `POST /v1/messages` | هدر `Retry-After: 60`. متن `rate limit exceeded: N requests per minute` است |

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8

{"error":{"code":"budget_exhausted","message":"this key has spent its request budget for the minute"}}
```

وضعیت ۴۲۹ تراکنشی شکل دیگری دارد:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
Content-Type: application/json; charset=utf-8

{"error":"rate limit exceeded: 2 requests per minute"}
```

میزبان ورود داده هرگز ۴۲۹ نمی‌دهد. هیچ محدودکننده نرخی ندارد.

## دوباره تلاش کنید {#retry}

همه این‌ها ۵۰۳ هستند و همه یعنی ایراد از ماست. عقب بکشید و همان payload را دوباره بفرستید.

| کد | HTTP | مسیر | علت |
|---|---|---|---|
| `budget_unavailable` | ۵۰۳ | هر مسیر مدیریتی به‌جز `GET /v1/status` | ردیس در دسترس نبود و بررسی بودجه بسته شکست می‌خورد |
| `ingest_unavailable` | ۵۰۳ | `POST /v1/events` | صف در دسترس نبود. به‌عمد ۵۰۳ است نه ۵۰۰: کسی که ۵۰۰ بگیرد فکر می‌کند payload خودش مشکل داشته و متوقف می‌شود |
| `segment_unavailable` | ۵۰۳ | `POST /v1/segments`، `PUT /v1/segments/{id}`، `DELETE /v1/segments/{id}` | ذخیره‌گاه سگمنت خطا داد |
| `campaign_unavailable` | ۵۰۳ | `POST /v1/campaigns`، `POST /v1/campaigns/{id}/send` | ذخیره‌گاه کمپین خطا داد |
| `approval_unavailable` | ۵۰۳ | ثبت و ارسال | ذخیره‌گاه تایید خطا داد. بسته شکست می‌خورد، پس ارسالی انجام نشده است |
| `export_unavailable` | ۵۰۳ | `GET /v1/exports`، `POST /v1/exports` | ذخیره‌گاه خروجی خطا داد |
| بدون کد، `error` تخت | ۵۰۳ | `POST /v1/messages` | متن `message not sent`. فقط وقتی هیچ شناسه پیامی برنگشته باشد. [ارسال تراکنشی](#transactional) را ببینید |
| بدون کد، `error` تخت | ۵۰۳ | خواندن اسکیما و شمارش و سگمنت و کمپین، و دو مسیر گزارش | انبار داده یا ذخیره‌گاه در دسترس نبود. روی گزارش‌ها متن یک جمله فارسی است |

در کل محصول فقط یک ۵۰۳ هدر `Retry-After` دارد و آن هم روی میزبان ورود داده است: خرابی جست‌وجوی کلید نوشتن `Retry-After: 5` می‌گذارد. بقیه ۵۰۳ها هیچ نمی‌گذارند. عقب‌نشینی خودتان را انتخاب کنید.

## پول بدهید، یا تیکت بزنید {#contact}

تلاش دوباره هیچ‌کدام از این‌ها را باز نمی‌کند. کسی باید فاکتوری را پرداخت کند، پلنی را ارتقا بدهد یا مجوزی بدهد.

| کد | HTTP | مسیر | یعنی چه |
|---|---|---|---|
| `quota_cancelled` | ۴۰۲ | `POST /v1/events` و هر مسیر ورود داده | اشتراک لغو شده است |
| `quota_trial_over` | ۴۰۲ | `POST /v1/events` و هر مسیر ورود داده | دوره آزمایشی تمام شده است |
| `quota_event_cap` | ۴۰۲ | `POST /v1/events` و هر مسیر ورود داده | حساب به سقف سخت رویداد پلنش در این ماه جلالی رسیده است |
| `quota_message_cap` | ۴۰۲ | هیچ‌جا به آن نمی‌رسد | مسیر کد وجود دارد. هیچ فراخوانی‌ای سنجه پیام را به بررسی سهمیه نمی‌دهد، پس هرگز رخ نمی‌دهد |
| `account_locked` | ۴۰۳ | `GET /v1/exports`، `POST /v1/exports`، `POST /v1/reports/funnel`، `POST /v1/reports/retention` | مصرف به سه برابر سهمیه رسیده، یا فاکتوری ۷۵ روز از سررسیدش گذشته است. مقدار `details` یکی از `{"reason":"usage_300"}` یا `{"reason":"overdue_75"}` است |

پیام‌های `quota_*` روی هر دو میزبان همیشه فارسی‌اند، هر چیزی که `Accept-Language` بگوید. این چهار جمله:

| کد | متن |
|---|---|
| `quota_cancelled` | `اشتراک این حساب لغو شده است` |
| `quota_trial_over` | `دورهٔ آزمایشی به پایان رسیده است` |
| `quota_event_cap` | `سقف رویدادهای این ماه پر شده است` |
| `quota_message_cap` | `سقف پیام‌های این ماه پر شده است` |

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

حکم سهمیه می‌داند حساب چقدر از سقف گذشته است. آن عدد به شما نمی‌رسد: پاسخ رد فقط جمله را دارد و بس. مصرف را از پنل بخوانید.

> [!danger]
> وضعیت ۴۰۲ باعث می‌شود هر SDK سگمنتیک دسته‌ای را که نگه داشته دور بریزد، نه اینکه بافرش کند. هر سه SDK هر ۴xx به‌جز ۴۲۹ را دائمی می‌شمارند. وقتی به سقف سخت رویداد بخورید، رویدادهایی که همان لحظه روی گوشی کاربران شما در صف بودند از دست می‌روند و بعدها هم برنمی‌گردند. هشدارهای مصرف را ببینید، نه پاسخ رد را.

## میزبان ورود داده وضعیت دارد، نه کد {#ingest-statuses}

روی `https://in.segmentic.net` فیلد `code` وجود ندارد. روی وضعیت HTTP شاخه بزنید.

| HTTP | متن | علت | کار |
|---|---|---|---|
| ۴۰۰ | `malformed JSON` | بدنه پارس نشد | درست کنید |
| ۴۰۰ | یکی از کلیدواژه‌های جدول پایین | یک رویداد از اعتبارسنجی رد نشد | درست کنید |
| ۴۰۰ | `batch_empty` | صفر قلم در `batch` | درست کنید |
| ۴۰۰ | `batch_too_large: N items, limit 500` | بیش از ۵۰۰ قلم | بشکنیدش |
| ۴۰۱ | `missing write key` | نه `Authorization: Bearer`، نه `X-Segmentic-Key`، نه `?write_key=` | درست کنید. هرگز تلاش دوباره نکنید |
| ۴۰۱ | `invalid write key` | کلید ناشناخته، باطل‌شده یا معلق | درست کنید. هرگز تلاش دوباره نکنید |
| ۴۰۲ | متن فارسی سهمیه | سقف سخت، اشتراک لغوشده، یا پایان دوره آزمایشی | تلاش دوباره نکنید |
| ۴۱۳ | `request body too large` | بزرگ‌تر از `5 MiB` | کمتر بفرستید |
| ۵۰۳ | `cannot verify the write key right now; retry` | جست‌وجوی کلید ما خطا داد. هدر `Retry-After: 5` می‌گذارد | تلاش دوباره، رویدادها را نگه دارید |
| ۵۰۳ | `temporarily unavailable, please retry` | هم گذرگاه پیام و هم بافر دیسک خطا دادند | تلاش دوباره |

دقت کنید که یک شرط روی دو میزبان دو وضعیت می‌گیرد: دسته بیشتر از ۵۰۰ قلم روی `POST /v1/batch` وضعیت ۴۰۰ می‌گیرد و روی `POST /v1/events` وضعیت ۴۱۳.

مسیرهای کانال و درون‌سایتی همین شکل را با متن‌های خودشان دارند.

| مسیر | HTTP | متن |
|---|---|---|
| `POST /v1/devices` | ۴۰۰ | `malformed JSON`، یا متن خود نرمال‌ساز به‌همراه `warnings` |
| `POST /v1/devices` | ۵۰۳ | `temporarily unavailable, please retry` |
| `POST /v1/devices/unregister` | ۴۰۰ | `device_id is required` |
| `POST /v1/webpush/subscribe` | ۴۰۰ | `user_id and a complete subscription are required` |
| `POST /v1/webpush/unsubscribe` | ۴۰۰ | `endpoint is required` |
| `POST /v1/messenger/link` | ۴۰۰ | `user_id, chat_id and a known platform are required` |
| `POST /v1/messenger/unlink` | ۴۰۰ | `user_id and a known platform are required` |
| `POST /v1/inbox` | ۴۰۰ | `user_id is required` |
| `POST /v1/inbox` | ۴۰۳ | `user identity is not verified` |
| `POST /v1/onsite/event` | ۴۰۰ | `campaign_id and a visitor id are required`، یا `unknown action` |
| `POST /v1/onsite/response` | ۴۰۰ | `unknown campaign`، یا متن خود اعتبارسنج |
| `POST /v1/hooks/{source}/{token}` | ۴۰۱ | `signature mismatch`، یا `unauthorized` |
| `POST /v1/hooks/{source}/{token}` | ۴۰۴ | `unknown webhook` |
| `POST /v1/bounce/{local}` | ۴۰۰ یا ۴۱۳ | متن ساده، نه JSON |

دو مسیر به‌عمد هرگز خرابی گزارش نمی‌کنند. `GET /v1/onsite` وقتی ذخیره‌گاهش پایین است `200 {"campaigns":[]}` می‌دهد، چون داخل بارگذاری صفحه شما اجرا می‌شود و کند شدن یا خطای گرفتن کمپین نباید به چشم بازدیدکننده شما بیاید. `POST /v1/onsite/event` همیشه ۲۰۰ است، حتی وقتی نوشتن نمایش خطا داده باشد. `POST /v1/hooks/...` برای payload ای که نمی‌تواند تبدیلش کند `200 {"status":"ok","accepted":0}` می‌دهد، چون شاپیفای و ووکامرس پاسخ غیر ۲xx را روزها تکرار می‌کنند.

## چرا یک رویداد رد شد {#rejection-reasons}

این ده رشته بخش پایدار `errors[].reason` روی میزبان ورود داده و `details[].reason` روی `POST /v1/events` هستند. مجموعه بسته است. هرچه بیرون از آن باشد `invalid` شمرده می‌شود.

| کلیدواژه | معنی |
|---|---|
| `unknown_type` | مقدار `type` یکی از track و identify و page و screen و alias نبود. با مقدار خودتان همراه است |
| `missing_identity` | نه `user_id` بود و نه `anonymous_id` |
| `missing_event_name` | نوع `track` بدون `event` |
| `event_name_too_long` | نام رویداد بیشتر از ۱۲۸ بایت |
| `event_name_invalid_chars` | نام رویداد نویسه کنترلی یونیکد دارد |
| `id_too_long` | یکی از `user_id` و `anonymous_id` و `message_id` بیشتر از ۲۵۶ بایت |
| `missing_previous_id` | نوع `alias` بدون `previous_id` |
| `batch_too_large` | بیشتر از ۵۰۰ قلم. با متن `batch_too_large: N items, limit 500` |
| `batch_empty` | صفر قلم |
| `timestamp_too_old` | فقط در backfill. ورود داده زنده به‌جایش زمان را می‌چسباند و هشدار می‌دهد |

## هشدارها، که روی ۲۰۰ می‌آیند {#warnings}

هشدار یعنی رویداد پذیرفته شد و چیزی اصلاح شد. خطا نیست و وضعیت را عوض نمی‌کند. هر هشدار `{"code","field","note"}` است و `field` و `note` وقتی خالی‌اند فرستاده نمی‌شوند.

| کد | فیلد | چه شد |
|---|---|---|
| `generated_message_id` | `message_id` | نفرستادید، پس ساخته شد. تلاش دوباره این رویداد دیگر تکراری‌زدایی نمی‌شود |
| `timestamp_in_future` | `timestamp` | ساعت دستگاه از سرور جلوتر است. به زمان دریافت چسبانده شد |
| `timestamp_too_old` | `timestamp` | قدیمی‌تر از پنجره ورود داده. به لبه پنجره چسبانده شد |
| `too_many_properties` | `properties` | بیشتر از ۲۵۶ ویژگی. ۲۵۶ تای اول نگه داشته شد |
| `too_many_traits` | `traits` | بیشتر از ۲۵۶ ویژگی پرونده. ۲۵۶ تای اول نگه داشته شد |
| `unserialisable_property` | همان کلید | مقدار قابل ذخیره نبود و حذف شد |
| `invalid_phone` | `phone` | شماره موبایل ایرانی معتبر نیست. همان‌طور که فرستادید ذخیره شد |
| `invalid_national_id` | `national_id` | رقم کنترلی نخواند. اصلا ذخیره نشد |

پاسخ یک دسته وقتی پنجاه هشدار جمع کرد جمع کردن را بس می‌کند، هرچند همه‌شان در سنجه‌های کیفیت داده حساب شما شمرده می‌شوند. حواستان به `generated_message_id` باشد: تنها هشداری است که برایتان خرج دارد، چون یعنی رویدادی که دوباره فرستاده شود دو بار ذخیره می‌شود.

این جدول مال میزبان ورود داده است. مسیر `POST /v1/events` روی میزبان مدیریتی همین هشدارها را حساب می‌کند و بعد دورشان می‌ریزد، پس پاسخش هیچ‌وقت آرایه `warnings` ندارد و از روی آن نمی‌فهمید زمانی چسبانده شده یا شناسه پیامی ساخته شده. اگر این در را برای مهاجرت داده به کار می‌برید، حساب کنید که هیچ سیگنالی از اصلاح‌ها نمی‌گیرید.

## خطاهای ارسال تراکنشی {#transactional}

مسیر `POST /v1/messages` قدیمی‌ترین هندلر میزبان مدیریتی است و از اول تا آخر شکل تخت می‌دهد. هیچ‌کدام از خرابی‌هایش `code` ندارند.

همه این‌ها ۴۰۰ هستند و کل رشته `error` همان متن کلیدواژه است:

| `error` | معنی |
|---|---|
| `transactional: user_id is required` | گیرنده‌ای نیست |
| `transactional: template_id is required` | قالبی نیست |
| `transactional: idempotency_key is required` | نه در بدنه و نه در هدر `Idempotency-Key` کلیدی نیست |
| `transactional: idempotency_key must be 8-200 characters of letters, digits, dot, dash, underscore or colon` | کلید با `^[A-Za-z0-9._:-]{8,200}$` نخواند. متن روی سیم بین ۸ و ۲۰۰ نویسه `U+2013` دارد |
| `transactional: unknown channel` | کانالی نیست که این حساب رویش بفرستد |
| `transactional: this endpoint does not send marketing; use a campaign` | مقدار `category` برابر `marketing` بود. این نقطه سقف تعداد و ساعت سکوت را دور می‌زند، پس قبول کردن پیام بازاریابی اینجا یعنی یک راه مستند برای دور زدن قواعد ارسال خودتان |
| `transactional: too many variables` | بیشتر از ۴۰ کلید در `vars` |
| `malformed JSON: <detail>` | پارس نشد، یا فیلد ناشناخته داشت. فیلد ناشناخته رد می‌شود نه نادیده گرفته، تا `idempotencyKey` بدغلط ۴۰۰ بگیرد و روی هر تلاش یک کلید تازه نسازد |

یک ۴۰۹ و یک ۵۰۳:

| HTTP | `error` | معنی |
|---|---|---|
| ۴۰۹ | `transactional: a message with this idempotency key is already in flight` | تلاش قبلی خودتان هنوز در جریان است. هدر `Retry-After: 1` |
| ۵۰۳ | `message not sent` | ارسال خطا داد و هیچ شناسه پیامی برنگشت |

> [!warn]
> وضعیت ۲۰۰ روی این نقطه یعنی تحویل داده شد نیست. فیلدهای `reason` و `reason_fa` را در بدنه بخوانید: وقتی پر می‌شوند که پیام به‌عمد فرستاده نشده باشد، برای انصراف کاربر، فهرست سیاه، یا نبود نشانی. و اگر ارسال انجام شد ولی ثبتش خطا داد، این نقطه به‌جای ۵۰۳ همان ۲۰۰ با نتیجه را می‌دهد، چون پیام به‌راستی رفته و کسی که خلافش را بشنود دوباره می‌فرستد و پیام دوم ارسال می‌شود.

## تلاش دوباره {#retrying}

قاعده‌ای که SDKها پیاده کرده‌اند، و همانی که باید خودتان پیاده کنید.

| وضعیت | تلاش دوباره | چرا |
|---|---|---|
| ۴۰۰، ۴۰۲، ۴۰۳، ۴۰۴، ۴۰۹، ۴۱۳، ۴۲۲ | نه | همان payload تا ابد همان جواب را می‌گیرد. استثنا ۴۰۹ روی `POST /v1/messages` است که تلاش خودتان است |
| ۴۰۱ | نه | اعتبارنامه هرگز کار نخواهد کرد. کلید را درست کنید |
| ۴۲۹ | بله، بعد از `Retry-After` | پنجره می‌چرخد |
| ۵xx | بله، با عقب‌نشینی نمایی و jitter | ایراد از ماست |

هر سه SDK سگمنتیک این را در یک خط گفته‌اند: هرچه در بازه ۴۰۰ تا ۴۹۹ باشد به‌جز ۴۲۹ دائمی است و دور ریخته می‌شود؛ بقیه با عقب‌نشینی نمایی و jitter کامل تکرار می‌شوند، از پایه ۱ ثانیه تا سقف ۵ دقیقه.

تفاوت ۴۰۱ و ۵۰۳ کل دلیل جدا بودنشان روی میزبان ورود داده است. SDK وضعیت ۴۰۱ را «این کلید هرگز کار نمی‌کند» می‌خواند، متوقف می‌شود و رویدادهای بافرشده را دور می‌ریزد؛ ۵۰۳ را «بعدها دوباره امتحان کن» می‌خواند و نگهشان می‌دارد. این میزبان پیش‌تر وقتی خود جست‌وجوی کلید خطا می‌داد هم ۴۰۱ می‌داد، که ایراد ماست نه کلید شما: با پایگاه داده خاموش‌شده، هشت رویداد از هشت رویداد ۴۰۱ گرفتند و سمت مشتری نابود شدند، در حالی که لاگ خودشان می‌گفت کلید نوشتنشان نامعتبر است. حالا آن حالت ۵۰۳ با `Retry-After: 5` است، و بافر دیسک پشت کالکتور درست برای همین هست که خرابی زیرساخت هیچ رویدادی را نبرد.

نتیجه برای شما: هر ۴۰۱ از ما را ایراد پیکربندی بدانید، نه گذرا. اگر روی کلیدی که دیروز کار می‌کرد ۴۰۱ انبوه دیدید، به‌راستی مشکل اعتبارنامه است، چون حالت خرابی دیگر این شکلی نیست.

## کجا تلاش دوباره محافظت‌شده است {#idempotency}

| مسیر | ساز و کار | پنجره |
|---|---|---|
| هر مسیر روی میزبان ورود داده | تکراری‌زدایی با `message_id` در ردیس | ۴۸ ساعت، با `DEDUPE_TTL` |
| `POST /v1/messages` | کلید `idempotency_key` خودتان، رزرو در دفتر با یک رفت و برگشت | ۷ روز، با `API_IDEMPOTENCY_RETENTION` |
| وب‌هوک‌های ورودی | شناسه پیام قطعی، تا تکرار پلتفرم بعد از timeout حالت عادی باشد | ۴۸ ساعت |

روی هر رویداد `message_id` بفرستید. بدون آن ما می‌سازیمش، شما هشدار `generated_message_id` می‌گیرید، و تلاش دوباره بعد از timeout شبکه رویداد را دو بار ذخیره می‌کند. با آن، تلاش دوباره رایگان است.

این محافظت روی `POST /v1/events` نیست. آن مسیر رویدادها را بی‌آنکه ردیس را ببیند مستقیم منتشر می‌کند، پس `message_id` آنجا فقط شناسه رویداد است و هیچ‌چیز تکراری را نمی‌گیرد. تلاش دوباره روی این در، حتی با همان `message_id`، رویداد را دو بار ذخیره می‌کند. قبل از فرستادن دوباره یک خواندن بزنید.

رویداد تکراری‌زدایی‌شده به‌عنوان پذیرفته‌شده گزارش می‌شود، نه به‌عنوان تکراری. هیچ فیلد `duplicate` روی سیم نیست و شمارش‌ها این دو را از هم جدا نمی‌کنند. این عمدی است: منطق تلاش دوباره شما نباید مجبور باشد اهمیت بدهد، و تکراری در سهمیه شما شمرده نمی‌شود.

وقتی ردیس در دسترس نیست، بررسی تکراری‌زدایی رد می‌شود و تکراری احتمالی پذیرفته می‌شود، به‌جای اینکه رویداد از دست برود.

ارسال تراکنشی بازپخش‌شده علامت دارد. فیلد `replayed` را در نتیجه بخوانید تا «پیش‌تر این کار را کردیم» را از «همین حالا کردیم» جدا کنید، که وقتی تلاش اولتان timeout خورده و نمی‌دانید کدامش شده مهم است.

> [!danger]
> هیچ نوشتن مدیریتی دیگری به‌جز `POST /v1/messages` کلید idempotency نمی‌گیرد. مسیرهای `POST /v1/segments` و `POST /v1/campaigns` و `POST /v1/campaigns/{id}/send` و `POST /v1/exports` و `POST /v1/events` چنین فیلدی ندارند، پس تلاش دوباره روی `POST /v1/campaigns` کمپین دوم می‌سازد. این‌ها را فقط بعد از یک خواندن که مطمئن شوید تلاش اول ننشسته دوباره بفرستید.

## شناسه پیگیری برای تیکت {#trace-id}

هر پاسخ از هر میزبان هدر `X-Segmentic-Trace` دارد، شانزده نویسه شانزده‌شانزدهی.

```http
HTTP/1.1 503 Service Unavailable
X-Segmentic-Trace: 4f2a9c81b0d3e756
Content-Type: application/json; charset=utf-8

{"error":{"code":"ingest_unavailable","message":"could not queue these events; retry"}}
```

در بدنه خطا نیست. از هدر لاگش کنید و در تیکت پشتیبانی بیاوریدش: همین است که به ما اجازه می‌دهد درست همان درخواست را در لاگ پیدا کنیم، نه دسته‌ای از درخواست‌های شبیه هم. اگر خودتان شناسه پیگیری دارید، آن را با همین هدر روی درخواست بفرستید: وقتی بین ۸ تا ۶۴ نویسه شانزده‌شانزدهی باشد، با حروف کوچک رعایتش می‌کنیم و در پاسخ برمی‌گردانیمش. هرچیز دیگری دور ریخته می‌شود و شناسه خودمان جایش می‌نشیند، چون این مقدار داخل خط‌های لاگ ما می‌رود.

نام هدر مال خودمان است و `traceparent` استاندارد کنسرسیوم وب نیست، چون پشتش نه تصمیم نمونه‌گیری هست و نه سلسله‌مراتب span، و هدری که شبیه `traceparent` باشد ولی نباشد اولین کسی را که ابزار ردیابی واقعی را به آن وصل کند گمراه می‌کند.

## آنچه در بدنه خطا نیست {#absent}

این‌ها را نوشته‌ایم چون هرکدام چیزی است که خواننده دنبالش می‌گردد و پیدا نمی‌کند.

- **فیلد `request_id` وجود ندارد.** شناسه پیگیری فقط در هدر است.
- **فیلد `message_fa` وجود ندارد.** شکل مدیریتی `code` و `message` و `details` و `need` دارد و بس. هرجا متنی فارسی باشد، در همان `message` فارسی است.
- **کد جدا برای خطاهای گزارش وجود ندارد.** سیزده خطای اعتبارسنجی موتور گزارش‌ها همه در یک کد `invalid_report` جمع می‌شوند. نه تای آن‌ها جمله فارسی کاتالوگ را در `error` می‌گذارند؛ چهار تای دیگر، یعنی نام رویداد بلند و فیلتر زیاد و کلید ویژگی بلند و مقدار ویژگی بلند، از شاخه پیش‌فرض رد می‌شوند و متن انگلیسی خود خطا را می‌آورند، مثل `analytics: too many filters`. پس زبان `error` روی این دو مسیر ثابت نیست. بدون خواندن آن رشته هم نمی‌توانید «مراحل قیف زیاد است» را از «بازه زمانی خیلی گسترده است» جدا کنید.
- **فیلدهای `used` و `limit` روی ۴۰۲ وجود ندارند.** حکم سهمیه هر دو را حساب می‌کند و قبل از نوشتن پاسخ دور می‌ریزد.
- **بودجه باقی‌مانده در `GET /v1/whoami` وجود ندارد.** پنج فیلد برمی‌گرداند و هیچ‌کدام بودجه نیست.
- **هدرهای `X-RateLimit-*` روی بودجه مدیریتی وجود ندارند.** فقط روی `POST /v1/messages` هستند، آن هم فقط وقتی محدودیت نرخی تنظیم شده باشد که به‌طور پیش‌فرض نشده است. [سقف‌ها](/docs/limits#budget) را ببینید.

مرتبط: [سقف‌ها و محدودیت نرخ](/docs/limits)، [API ورود داده](/docs/api/ingest)، [API مدیریتی](/docs/api/management).

---

# سقف‌ها و محدودیت نرخ

> هر عددی که سرور به آن پایبند است، و اینکه کدام‌یک را می‌شود در زمان اجرا از خود سرور پرسید.
> https://segmentic.net/docs/limits

هر عددی که در این صفحه هست از همان کدی می‌آید که اعمالش می‌کند. هرجا یک سقف به‌جای رد کردن، داده شما را کوتاه می‌کند، همان‌جا صریح گفته شده است، چون کوتاه شدن بی‌صدا همان خرابی‌ای است که ماه‌ها بعد از یک گزارش بی‌معنی می‌فهمید.

## سقف‌ها را از سرور بخوانید {#ask-the-server}

مسیر `GET /v1/capabilities` پنج تا از این عددها را در زمان اجرا منتشر می‌کند. یک واحد بودجه خرج دارد و هیچ مجوزی نمی‌خواهد.

```bash
curl -s https://api.segmentic.net/v1/capabilities \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "version": "v1",
  "features": {
    "segments": true,
    "campaigns": true,
    "analytics": true,
    "transactional": true,
    "export": true,
    "import": true,
    "journeys": true,
    "ingest": true,
    "async_exports": true,
    "campaign_approval": true
  },
  "limits": {
    "max_page_size": 100,
    "max_preview_rows": 100,
    "max_batch_size": 500,
    "estimate_sample": 100,
    "query_timeout_sec": 30
  }
}
```

مقدارهای `max_page_size` و `max_batch_size` را از همین‌جا بخوانید، نه اینکه ۱۰۰ و ۵۰۰ را در کد بنویسید. هر پرچم در `features` می‌گوید همین استقرار در عمل چه چیزی سرو می‌کند، پس نصب خودمیزبانی که موتور تحلیل ندارد `"analytics": false` می‌دهد و کلاینت شما می‌تواند موقع بالا آمدن شکست بخورد به‌جای وسط کار.

چهار هشدار درباره این پاسخ، هر چهار چیزی که خواننده فرض می‌گیرد و نباید بگیرد.

- مقدارهای `max_preview_rows` و `estimate_sample` روی میزبان مدیریتی هیچ‌چیز را محدود نمی‌کنند. مال دو نقطه در پنل‌اند که اینجا ثبت نشده‌اند. نادیده بگیریدشان.
- مقدار `query_timeout_sec` برابر ۳۰ است و مهلت خواندن‌ها و نوشتن‌ها و `POST /v1/audiences/count` است. مهلتی که دو مسیر گزارش استفاده می‌کنند نیست، آن ۴۵ ثانیه است. [مهلت‌ها](#timeouts) را ببینید.
- کلیدهای `ingest` و `import` یک بولین‌اند با دو اسم، همانی که `POST /v1/events` را روشن می‌کند.
- پرچم `export` هیچ مسیری را روی این میزبان کنترل نمی‌کند. دو مسیر خروجی را `async_exports` روشن و خاموش می‌کند. حسابی که `"export": false` می‌بیند ممکن است هر دو مسیر خروجی را داشته باشد، و برعکس. برای خروجی به `async_exports` نگاه کنید.

بقیه چیزهای این صفحه را ناچارید در کد بنویسید، چون جایی برای خواندنشان نیست. نه `GET /v1/reports/limits` وجود دارد و نه نقطه‌ای که سقف رویداد حساب شما یا مصرف فعلی‌اش را بگوید.

## سقف‌های ورود داده {#ingest-limits}

این‌ها روی هر دو در اعمال می‌شوند: میزبان ورود داده، و `POST /v1/events` روی میزبان مدیریتی.

| سقف | مقدار | روی چه | وقتی رد شود |
|---|---|---|---|
| طول نام رویداد | `128 bytes` | فیلد `event` در track | رد می‌کند، `event_name_too_long` |
| طول شناسه | `256 bytes` | `user_id` و `anonymous_id` و `message_id` | رد می‌کند، `id_too_long` |
| طول شناسه نشست | `256 bytes` | `context.session_id` | بی‌صدا کوتاه می‌کند |
| شناسه قبلی | بدون سقف | `previous_id` در alias | فقط وجودش بررسی می‌شود، هرگز طولش |
| کلید ویژگی | `128 bytes` | هر کلید، بعد از نرمال‌سازی | بی‌صدا کوتاه می‌کند |
| مقدار ویژگی | `8192 bytes` | هر مقدار رشته‌ای | بی‌صدا کوتاه می‌کند |
| تعداد ویژگی در رویداد | `256` | `properties` | ۲۵۶ تای اول را نگه می‌دارد، `too_many_properties` |
| تعداد ویژگی در identify | `256` | `traits` | ۲۵۶ تای اول را نگه می‌دارد، `too_many_traits` |
| تعداد رویداد در دسته | `500` | `POST /v1/batch` و `POST /v1/events` | رد می‌کند، `batch_too_large` |
| بدنه درخواست | `5 MiB` | کل درخواست روی میزبان ورود داده | رد می‌کند، ۴۱۳ |
| نشانی صفحه | `2048 bytes` | `context.page.url` و `.path` و `.referrer` | بی‌صدا کوتاه می‌کند |
| بدنه وب‌هوک | `1 MiB` | `POST /v1/hooks/{source}/{token}` | رد می‌کند |
| گزارش برگشتی | `1 MiB` | `POST /v1/bounce/{local}` | رد می‌کند، ۴۱۳ |

فقط یکی از این‌ها کلید پیکربندی دارد. کلید `MAX_BODY_BYTES` سقف بدنه را می‌گذارد و پیش‌فرضش `5242880` است. بقیه در کد کامپایل شده‌اند، پس نصب خودمیزبان هم نمی‌تواند بالاترشان ببرد.

ترتیب مهم است. رد کردن قبل از کوتاه کردن رخ می‌دهد و کوتاه کردن قبل از ذخیره، پس رویدادی با `user_id` سیصدبایتی کامل رد می‌شود، نه اینکه با شناسه کوتاه‌شده ذخیره شود.

### سقف‌های بی‌صدا {#truncation}

هر فیلد دیگری داخل `context` بدون هیچ هشدار و هیچ خطایی کوتاه می‌شود. اینجا آورده شده‌اند چون راه دیگر فهمیدنشان این است که یک سگمنت آدم‌هایی را که انتظار داشتید نگیرد.

| بایت | فیلدها |
|---|---|
| `8` | ویژگی `currency`، با حروف بزرگ |
| `16` | `context.device.push_provider` |
| `32` | `context.locale` و `context.device.type` و `context.os.name` و `context.os.version` و `context.library.version`، و نسخه مرورگر که از User-Agent درمی‌آید |
| `64` | `context.timezone` و `context.ip` و `context.app.version` و `context.device.manufacturer` و `context.network.carrier` و `context.library.name` و `context.location.country` و `context.location.region` و `context.location.city` و `context.campaign.variant_id`، و نام مرورگر و سازنده دستگاه که از User-Agent درمی‌آیند |
| `128` | `context.device.model` و `context.campaign.token` و هر پنج فیلد `utm`: source و medium و name و term و content |
| `256` | `context.session_id` و `context.campaign.message_id` |
| `512` | `context.page.title`، و خود هدر `User-Agent` |

کشور و استان و شهر و عنوان صفحه و هر نام رویداد پیش از کوتاه شدن از نرمال‌سازی فارسی هم رد می‌شوند، پس ی و ک عربی به ی و ک فارسی تبدیل می‌شوند. همین است که می‌گذارد سگمنت روی `city = تهران` مشتری‌هایی را بگیرد که اپ‌هایشان سر املا با هم اختلاف دارند.

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

## پنجره زمانی رویداد {#timestamps}

هر رویداد `timestamp` خودش را دارد. اینکه چقدر می‌تواند به عقب برسد، نگهداشت رویداد حساب خودتان است، نه یک ثابت.

| تنظیم | مقدار | رفتار |
|---|---|---|
| پنجره پیش‌فرض گذشته | ۳۰ روز | کف. حسابی که نگهداشتش ۳۰ روز است هم همان ۳۰ روز کامل را می‌گیرد |
| پنجره آینده | ۱ ساعت | جلوتر از این، به زمان دریافت چسبانده و `timestamp_in_future` هشدار داده می‌شود |
| نگهداشت روی «برای همیشه» | ۳۶۵۰ روز | ده سال، که با وجود اسمش متناهی است تا زمان ۱۹۷۰ از یک ساعت خراب همچنان رد شود. پنجره پیش‌فرض حسابی است که هرگز صفحه نگهداشت را باز نکرده |

در ورود داده زنده، زمانی قدیمی‌تر از پنجره **به لبه پنجره چسبانده می‌شود** و رویداد با هشدار `timestamp_too_old` پذیرفته می‌شود. رد نمی‌شود.

این تنها رفتار پلتفرم است که بیشترین احتمال را دارد یک هفته از شما بگیرد. پیش‌تر همان ۳۰ روز کل پنجره بود، هرچه نگهداشت شما می‌گفت، و نشانه‌اش نامرئی بود: حسابی که دو سال تاریخچه را مهاجرت می‌داد، هر رویداد قدیمی‌تر از سی روز را بی‌صدا درست روی «سی روز پیش» می‌دید، پذیرفته‌شده با یک هشدار و کد ۲۰۰. هیچ‌چیز شکست نخورد. داده فقط غلط بود، همه‌اش روی یک زمان تلنبار، و اولین نشانه‌اش ماه‌ها بعد قیفی بود که هیچ معنی نمی‌داد. حالا پنجره از سیاست نگهداشت شما می‌آید، همان چیزی که این چسباندن همیشه ادعا می‌کرد اعمالش می‌کند.

خواندن نگهداشت یک دقیقه کش می‌شود و وقتی خوانده نشود نرم به ۳۰ روز برمی‌گردد.

در backfill همین شرط به‌جای چسباندن **رد می‌کند**، با `timestamp_too_old`، چون در backfill جابه‌جا کردن یک زمان بدتر از رد کردنش است. سطر backfill که هیچ زمانی نداشته باشد هم رد می‌شود.

> [!danger]
> پنجره‌ای که از نگهداشت می‌آید فقط روی میزبان ورود داده اعمال می‌شود. مسیر `POST /v1/events` روی میزبان مدیریتی سیاست حساب شما را نمی‌خواند و همیشه همان پیش‌فرض ۳۰ روز را می‌گیرد، چون گزینه‌هایش را بدون `MaxPast` می‌سازد. هر رویداد قدیمی‌تر از سی روز که از این در بفرستید بی‌صدا روی «سی روز پیش» می‌نشیند و ۲۰۰ می‌گیرد، و پاسخ هیچ هشداری هم ندارد چون این مسیر هشدارها را دور می‌ریزد. تاریخچه را هرگز از اینجا مهاجرت ندهید. وارد کردن رویداد در پنل به‌جای چسباندن رد می‌کند، و رد کردن همان چیزی است که مهاجرت لازم دارد.

### نگهداشت پنجره ورود است، نه عمر داده {#storage-ttl}

این دو عدد یکی نیستند و اشتباه گرفتنشان گران است. سیاست نگهداشت تا ۳۶۵۰ روز اعتبارسنجی می‌شود و کف غیرصفرش ۳۰ روز است. ولی جدول رویدادها در کلیک‌هاوس یک `TTL` ثابت **چهارصد روزه** روی `event_time` دارد که به سیاست حساب کار ندارد و هر سطری را می‌اندازد.

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

اگر تاریخچه بلندتر از چهارصد روز لازم دارید، پیش از رسیدن به آن مرز خودتان خروجی بگیرید. جای دیگری برای گرفتنش نیست.

## سقف‌های میزبان مدیریتی {#management-limits}

| سقف | مقدار | روی چه |
|---|---|---|
| بدنه درخواست | `8 MiB` | بیشتر مسیرها |
| بدنه درخواست | `256 KiB` | `POST /v1/messages` |
| بدنه درخواست | `1 MiB` | `POST /v1/audiences/validate` و `POST /v1/audiences/count` و دو مسیر گزارش |
| بیشترین اندازه صفحه | `100` | هر مسیر فهرست |
| اندازه صفحه پیش‌فرض | `25` | هر مسیر فهرست |
| نوع خروجی | `events` و `messages` و `profiles` و `segment` | `POST /v1/exports` |
| قالب خروجی | پیش‌فرض `ndjson`، تنها جایگزین `csv` | `POST /v1/exports` |
| عمر فایل خروجی | ۷ روز | در بدنه ۲۰۲ با `expires_after_hours: 168` منتشر می‌شود |

سه سقف مختلف بدنه اشتباهی نیست که کسی درستش کرده باشد. مسیر `POST /v1/messages` سهمش `256 KiB` است چون payload تراکنشی یک شناسه قالب است و چند متغیر، و آن چهار مسیری که هندلر پنل را قرض می‌گیرند همان `1 MiB` هندلر را می‌گیرند.

### صفحه‌بندی {#pagination}

پارامتر `limit` بریده می‌شود، هرگز رد نمی‌شود. مقدار `limit=500` به شما ۱۰۰ می‌دهد. مقدارهای `limit=0` و `limit=banana` هر دو ۲۵ می‌دهند. خطایی نمی‌گیرید که سقف را بگوید؛ آن را از `GET /v1/capabilities` بخوانید.

کرسر یک رشته مبهم `base64url` است. کرسری که دیکد نشود بی‌صدا ابتدای فهرست فرض می‌شود، نه خطا، پس کلاینتی که کرسرش را خراب کند تا ابد روی صفحه یک می‌چرخد به‌جای اینکه شکست بخورد. شناسه‌هایی را که می‌گیرید با آنچه دارید مقایسه کنید.

> [!warn]
> مسیر `GET /v1/exports` پارامتر `limit` را می‌گیرد ولی هرگز کرسر برنمی‌گرداند. پاسخش `{"data":[...],"has_more":false}` است و `has_more` حتی وقتی کار بیشتری هست هم `false` است. هیچ راهی برای رفتن به صفحه بعد نیست. بیشترین مقدار یعنی ۱۰۰ را بخواهید و همان را کل فهرست بدانید.

مسیرهای `GET /v1/segments` و `GET /v1/campaigns` با شکل خود هندلر پنل جواب می‌دهند، یعنی `{"segments":[...]}` و `{"campaigns":[...]}`، نه با پاکت صفحه‌بندی. شکل `data` و `next_cursor` فقط روی `GET /v1/exports` ظاهر می‌شود.

> [!danger]
> هر دوی آن دو مسیر در خود SQL روی ۲۰۰ سطر بریده شده‌اند: دویست سگمنتی که تازه‌تر از همه به‌روز شده‌اند، و دویست کمپینی که تازه‌تر از همه به‌روز شده‌اند. سقف بی‌صداست. نه `has_more` هست، نه `next_cursor`، نه شمارشی، نه هشداری، و `?limit=` و `?cursor=` هم اینجا خوانده نمی‌شوند. حسابی که ۲۵۰ سگمنت دارد دویست‌تا را می‌بیند و روی هیچ سطحی راهی به آن ۵۰ تای دیگر ندارد، حتی در پنل. اگر کتابخانه شما از این عدد بگذرد، فهرست خودتان را جای دیگری نگه دارید و شناسه را مستقیم به `GET /v1/segments/{id}` بدهید.

## سقف‌های گزارش {#report-limits}

برای `POST /v1/reports/funnel` و `POST /v1/reports/retention`.

| سقف | مقدار | چه چیزی را می‌بندد |
|---|---|---|
| بازه زمانی | ۷۳۰ روز | گسترده‌ترین پنجره‌ای که این دو گزارش اسکن می‌کنند. دو سال برای یک حساب بزرگ همین حالا صدها میلیارد سطر است |
| مراحل قیف | ۱۲ | برای هر مرحله یک شرط ساخته می‌شود و آدم باید نتیجه را بخواند |
| دوره‌های ماندگاری | ۶۰ | تعداد ستون‌های جدولی که کسی باید بخواند. اگر صفر یا کمتر بفرستید ۳۰ می‌شود |
| فیلتر در هر گزارش | ۱۰ | |
| طول کلید ویژگی | ۱۲۸ | |
| طول نام رویداد | ۲۵۶ | |
| طول مقدار ویژگی | ۵۱۲ | |
| عمق مسیر | ۸ | گزارش مسیرها، که روی این میزبان نیست |
| سطرهای مسیر | ۱۰۰ | گزارش مسیرها، که روی این میزبان نیست |

هر کدام از این خرابی‌ها با یک کد برمی‌گردد، `invalid_report`. پشت آن یک کد سیزده خطای اعتبارسنجی متمایز هست و هیچ راه برنامه‌ای برای جدا کردنشان. نه تای آن‌ها جمله فارسی کاتالوگ را در `error` می‌گذارند؛ چهار سطر پایینی این جدول، یعنی طول نام رویداد و تعداد فیلتر و طول کلید ویژگی و طول مقدار ویژگی، از شاخه پیش‌فرض رد می‌شوند و متن انگلیسی خود خطا را می‌آورند، مثل `analytics: too many filters`. اگر لازم دارید روی رشته تطبیق بدهید، و بپذیرید که نه رشته قرارداد است و نه زبانش.

گزارش مسیرها وجود دارد ولی فقط روی mux پنل ثبت شده است. با کلید API دسترس‌پذیر نیست.

## سقف‌های مخاطب و سگمنت {#segment-limits}

این‌ها تعریفی را می‌بندند که به `POST /v1/audiences/validate` و `POST /v1/audiences/count` و `POST /v1/segments` و `PUT /v1/segments/{id}` می‌فرستید.

| سقف | مقدار | متن خطا |
|---|---|---|
| عمق تودرتویی | ۸ | `segment: nesting too deep` |
| تعداد شرط | ۲۰۰ | `segment: too many conditions` |
| مقدار در یک فهرست | ۱۰۰۰ | `segment: list has too many values` |
| طول کلید ویژگی | ۱۲۸ | |

شمارش شرط‌ها گزاره‌های روی ویژگی رویداد را هم شامل می‌شود، نه فقط گره‌های درخت منطقی، پس تعریفی که روی صفحه بیست شرط به نظر می‌رسد می‌تواند اینجا خیلی بیشتر از صد باشد. هر چهارتا با کد `filter_invalid` و وضعیت ۴۲۲ و جمله خود کامپایلر به‌عنوان متن بیرون می‌آیند.

قبل از ذخیره اعتبارسنجی کنید. مسیر `POST /v1/audiences/validate` تعریف را بدون دست زدن به پایگاه داده کامپایل می‌کند، یک واحد بودجه خرج دارد، و به‌جای ۲۰۰ با `valid: false` که پنل می‌دهد، ۴۲۲ می‌دهد.

## سقف‌های ارسال تراکنشی {#transactional-limits}

| سقف | مقدار | کلید پیکربندی |
|---|---|---|
| متغیر در هر پیام | ۴۰ | ندارد |
| قالب کلید idempotency | `^[A-Za-z0-9._:-]{8,200}$` | ندارد |
| عمر کلید idempotency | ۷ روز | `API_IDEMPOTENCY_RETENTION`، پیش‌فرض `168h` |
| مهلت رزرو مانده | ۱ دقیقه | `API_STALE_RESERVATION` |
| بدنه درخواست | `256 KiB` | ندارد |

قالب کلید به‌عمد سخت‌گیر است. کلید بخشی از شناسه پیام می‌شود، و شناسه پیام در دفتر و شمارنده تعداد و مرجع خود ارائه‌دهنده نوشته می‌شود، پس کلیدی که خط جدید یا گیومه داشته باشد راه درازی می‌رفت تا جایی بالاخره ردش کند. شناسه پیام مشتق است نه ساخته‌شده: `t<tenant_id>.<کلید شما>`، پس یک کلید تا آخر خط همان شناسه را می‌سازد.

فیلد `category` وقتی نفرستیدش `transactional` می‌شود. مقدار `marketing` با ۴۰۰ مستقیم رد می‌شود. این نقطه سقف تعداد و ساعت سکوت را دور می‌زند، پس قبول کردن پیام بازاریابی اینجا یعنی به شما یک راه مستند برای دور زدن قواعد ارسال خودتان بدهیم، و اولین باری که مهم می‌شد یک پیامک تبلیغاتی ساعت سه بامداد به کل فهرست بود.

## بودجه درخواست {#budget}

هر مسیر میزبان مدیریتی به‌جز `GET /v1/status` با واحد وزن‌دار سنجیده می‌شود. «درخواست در دقیقه» واحد غلطی است برای سطحی که یک فراخوانی‌اش یک ساختار را می‌خواند و فراخوانی بعدی‌اش یک انبار داده را اسکن می‌کند.

| کلاس | وزن | یعنی چه |
|---|---|---|
| ساده | `1` | چیزی نمی‌خواند، یا یک سطر با کلید اصلی می‌خواند |
| کوئری | `5` | یک کوئری کران‌دار روی انبار داده |
| سنگین | `25` | اسکنی که هزینه‌اش با تاریخچه شما بزرگ می‌شود |

هزینه هر مسیر:

| مسیر | هزینه | مجوز |
|---|---|---|
| `GET /v1/whoami` | `1` | ندارد |
| `GET /v1/capabilities` | `1` | ندارد |
| `GET /v1/schema/events` | `5` | `event.read` |
| `GET /v1/schema/traits` | `5` | `event.read` |
| `POST /v1/audiences/validate` | `1` | `segment.read` |
| `POST /v1/audiences/count` | `25` | `segment.read` |
| `GET /v1/segments` | `1` | `segment.read` |
| `GET /v1/segments/{id}` | `1` | `segment.read` |
| `POST /v1/segments` | `1` | `segment.write` |
| `PUT /v1/segments/{id}` | `1` | `segment.write` |
| `DELETE /v1/segments/{id}` | `1` | `segment.delete` |
| `GET /v1/campaigns` | `1` | `campaign.read` |
| `GET /v1/campaigns/{id}` | `5` | `campaign.read` |
| `POST /v1/campaigns` | `1` | `campaign.write` |
| `PUT /v1/campaigns/{id}/recurrence` | `1` | `campaign.send` |
| `DELETE /v1/campaigns/{id}/recurrence` | `1` | `campaign.send` |
| `POST /v1/campaigns/{id}/send` | `1` | `campaign.send` |
| `POST /v1/campaigns/{id}/submit` | `1` | `campaign.write` |
| `POST /v1/events` | `5` | `profile.write` |
| `GET /v1/exports` | `1` | `data.export` |
| `POST /v1/exports` | `25` | `data.export` |
| `POST /v1/reports/funnel` | `25` | `analytics.read` |
| `POST /v1/reports/retention` | `25` | `analytics.read` |
| `POST /v1/messages` | `1` | `campaign.send` |

ساز و کارش:

| ویژگی | مقدار |
|---|---|
| سهمیه پیش‌فرض | `600` واحد در دقیقه، از `PUBLIC_API_BUDGET_PER_MINUTE` |
| الگوریتم | پنجره ثابت، یک رفت و برگشت به ردیس |
| پنجره | یک دقیقه ساعت دیواری، نه دقیقه غلتان |
| دامنه | به ازای هر کلید API، نه هر حساب |
| جهت خرابی | بسته شکست می‌خورد. ردیس در دسترس نباشد یعنی ۵۰۳ با `budget_unavailable` |
| زمان برداشت | قبل از اجرای هندلر، و حتی وقتی هندلر بعدش شکست بخورد هم برداشته می‌شود |

۶۰۰ واحد کم‌وبیش یعنی دو دوجین گزارش سنگین در دقیقه، یا ششصد فراخوانی ارزان. دقیقش: ۲۴ فراخوانی سنگین می‌گذرد و بیست‌وپنجمی رد می‌شود.

بودجه برخلاف محدودیت نرخ پایین‌تر، به کلید API کلید خورده است نه به حساب، و این عمدی است. شما یک کلید باریک به یک ایجنت می‌دهید و کلید یکپارچه‌سازی خودتان را جدا نگه می‌دارید، و ایجنتی که در حلقه افتاده نباید بتواند بودجه‌ای را که خط سفارش شما به آن وابسته است تمام کند. [MCP](/docs/mcp) را ببینید.

هزینه `POST /v1/messages` برابر ۱ است، که برای فراخوانی‌ای که می‌تواند به گوشی یک آدم پیام بفرستد غلط به نظر می‌رسد. غلط نیست: هزینه آن فراخوانی از نظر کوئری ناچیز است و از نظر پیامد عظیم، و چیزی که قرار بود بندش بزند بودجه گیرنده است، که وجود ندارد. [آنچه سقف ندارد](#no-limit) را ببینید.

> [!warn]
> روی بودجه هیچ هدر `X-RateLimit-*` وجود ندارد. نه روی پاسخ‌های رد و نه روی پاسخ‌های موفق. مسیر `GET /v1/whoami` هم بودجه باقی‌مانده را نمی‌گوید. نمی‌توانید ببینید چقدر نزدیک شده‌اید: یا خرج خودتان را از جدول بالا بشمارید، یا ۴۲۹ را مدیریت کنید.

تنها راه برگشت زمان است. کلید ردیس ۷۰ ثانیه بعد از اولین برداشت در آن پنجره منقضی می‌شود و سطل دقیقه با ساعت دیواری می‌چرخد. نه نقطه‌ای برای صفر کردن هست، نه بازنویسی به ازای کلید، و نه راهی برای بالا بردن سهمیه جز عوض کردن متغیر محیطی و ری‌استارت پروسه.

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

## محدودیت نرخ ارسال تراکنشی {#rate-limit}

ساز و کار دومی که به‌کلی جداست. به‌جای واحد هزینه، درخواست می‌شمارد، دامنه‌اش حساب است نه کلید، و در جهت مخالف شکست می‌خورد.

| ویژگی | مقدار |
|---|---|
| روی چه اعمال می‌شود | فقط `POST /v1/messages` و هیچ‌چیز دیگر |
| الگوریتم | پنجره ثابت، با `INCR` ردیس |
| پنجره | یک دقیقه ساعت دیواری |
| دامنه | به ازای هر حساب. چرخاندن کلید سهمیه تازه نمی‌خرد |
| منبع سقف | فیلد `api_rate_per_minute` خود حساب شما، روی هر درخواست و بدون کش خوانده می‌شود |
| پیش‌فرض | `API_RATE_PER_MINUTE`، که با مقدار `0` منتشر شده است |
| مقدار `0` یعنی | اندازه‌گیری به‌کلی خاموش. این پیش‌فرض منتشرشده است |
| جهت خرابی | باز شکست می‌خورد. ردیس در دسترس نباشد یعنی فراخوانی رد می‌شود جلو |

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

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

وقتی سقفی تنظیم شده باشد، دو هدر روی هر پاسخ می‌آیند، حتی پاسخ‌های موفق، تا بتوانید قبل از رد شدن آهسته کنید.

```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
Content-Type: application/json; charset=utf-8
```

مقدار `X-RateLimit-Remaining` هرگز منفی نمی‌شود. هدر `X-RateLimit-Reset` و هدر `X-RateLimit-Resource` وجود ندارند. با پیش‌فرض منتشرشده که صفر است، هیچ هدری فرستاده نمی‌شود.

میزبان ورود داده هیچ محدودکننده نرخی ندارد. تنها کنترل‌های حجمش سقف بدنه، سقف دسته و دروازه سهمیه‌اند.

## سهمیه {#quota}

سهمیه سقف پلن است که روی یک ماه جلالی شمرده می‌شود.

| سنجه | چه می‌شمارد |
|---|---|
| `events` | هرچه کالکتور پذیرفته، از جمله تکراری‌های زدوده‌شده |
| `profiles` | بیشترین تعداد آدم شناسایی‌شده در دوره، نه جمعشان |
| `messages.email` و `messages.sms` و `messages.push` و `messages.web` و `messages.inapp` | ارسال‌ها، یک سنجه برای هر کانال |
| `messages.messenger` | بله و ایتا و روبیکا با هم در یک سنجه |

رویدادها فقط بعد از پذیرفته شدن سنجیده می‌شوند، پس payload ردشده صورتحساب نمی‌شود و تلاش دوباره تکراری‌زدایی‌شده هم نمی‌شود. SDK ای که دوباره می‌فرستد برای ما یک جست‌وجوی ردیس خرج دارد، نه یک خط فاکتور که سرش با ما دعوا کنید. پیام‌ها فقط روی ارسال موفق سنجیده می‌شوند.

پلن دو سقف سخت دارد، `max_events` و `max_messages`، که از سهمیه شامل جدا هستند. صفر یعنی بی‌سقف: مازاد صورتحساب می‌شود و همه‌چیز کار می‌کند. رد شدن از سهمیه شامل، پول خرج دارد و یک فاکتور است. رد شدن از سقف، شما را متوقف می‌کند و یک قطعی است.

**فقط سقف رویداد اعمال می‌شود.** هیچ مسیر کدی سنجه پیام را در برابر `max_messages` بررسی نمی‌کند، پس `quota_message_cap` هرگز رخ نمی‌دهد. این فیلد تعریف شده، اعتبارسنجی شده، روی فاکتور چاپ می‌شود، و هیچ‌جا اعمال نمی‌شود.

وقتی به سقف برسید چه می‌شود:

- درخواست کامل با ۴۰۲ رد می‌شود، هرگز نیم‌بند پذیرفته نمی‌شود. پذیرش جزئی شما را ناتوان می‌گذاشت از اینکه بفهمید کدام رویدادها را دوباره بفرستید، و به هر حال از سقف رد شده‌اید.
- بررسی یک بار برای کل دسته اجرا می‌شود، پیش از هر کار قلم‌به‌قلم.
- هر SDK دسته‌ای را که نگه داشته دور می‌ریزد، چون هر سه هر ۴xx به‌جز ۴۲۹ را دائمی می‌شمارند. آن رویدادها رفته‌اند.
- تلاش دوباره تا کسی پول ندهد هیچ‌چیز را عوض نمی‌کند.

حکم سهمیه ۱۵ ثانیه به ازای هر حساب کش می‌شود، پس می‌توانید به اندازه هرچه در آن پنجره بفرستید از سقف بزنید بیرون. دروازه باز هم شکست می‌خورد: اگر خواندن اشتراک یا مصرف خطا بدهد، ترافیک پذیرفته می‌شود.

دوره یک **ماه جلالی** است نه میلادی، و روزهای داخلش به وقت محلی تهران شمرده می‌شوند. دوره درست همان‌جا تمام می‌شود که دوره بعد شروع می‌شود، پس هیچ لحظه‌ای بین دو دوره نمی‌افتد.

اعلان‌ها، که چیزی را رد نمی‌کنند، روی ۳۰۰ و ۱۵۰ و ۱۲۵ و ۱۰۰ و ۸۰ درصد سهمیه شامل رخ می‌دهند، هر ماه جلالی یک بار برای هر سنجه.

## قفل نرم {#soft-lock}

یک کنترل مالی است، نه محدودیت نرخ.

| محرک | آستانه |
|---|---|
| فاکتور سررسیدگذشته | فاکتور صادرشده‌ای که دست‌کم ۷۵ روز از سررسیدش گذشته باشد. اعلام واریز خلعش می‌کند |
| مصرف | پرونده‌ها یا رویدادها روی ۳۰۰ درصد سهمیه شامل یا بالاتر |

اول سررسید بررسی می‌شود. حکم ۶۰ ثانیه به ازای هر حساب کش می‌شود و روی هر مسیری که نتواند بخواند، باز شکست می‌خورد. عدد پرونده‌ها تا یک ساعت عقب است، چون یک کار پس‌زمینه می‌نویسدش.

درست چهار مسیر میزبان مدیریتی را می‌بندد: `GET /v1/exports` و `POST /v1/exports` و `POST /v1/reports/funnel` و `POST /v1/reports/retention`.

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

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

## مهلت‌ها {#timeouts}

| فراخوانی | سقف سمت سرور |
|---|---|
| `GET /v1/whoami` و `GET /v1/capabilities` و `GET /v1/status` | هیچ کار پایگاه داده ندارند |
| هر خواندن، هر نوشتن، `POST /v1/audiences/count`، `POST /v1/messages` | `30 s`، مهلت کوئری |
| `POST /v1/reports/funnel` و `POST /v1/reports/retention` | هندلر `45 s` می‌دهد، ولی شنونده پاسخ را سر `30 s` می‌برد |
| هر فراخوانی روی میزبان مدیریتی | `WriteTimeout 30 s`، `ReadTimeout 15 s`، `ReadHeaderTimeout 5 s` |
| هر فراخوانی روی میزبان ورود داده | `WriteTimeout 30 s`، `ReadTimeout 15 s`، `IdleTimeout 120 s` |

سطر گزارش‌ها یک تناقض واقعی است، نه اختلاف گردکردن. قیفی که بین ۳۰ و ۴۵ ثانیه طول بکشد را سرور HTTP می‌برد، نه هندلر، پس به‌جای یک خطای تمیز یک پاسخ ناقص می‌بینید. بازه زمانی یا تعداد مراحل را کم کنید.

مهلت **۳۵ ثانیه** روی کلاینت هر سقفی که اینجا هست را پوشش می‌دهد. بالاتر رفتن چیزی به شما نمی‌دهد، چون شنونده به هر حال سر ۳۰ ثانیه اتصال را می‌بندد.

## آنچه سقف ندارد {#no-limit}

هرکدام از این‌ها سقفی است که خواننده انتظار دارد پیدا کند. هیچ‌کدامشان وجود ندارد.

- **سقفی روی تعداد نام رویداد یا کلید ویژگی متمایز نیست.** مقدارهای `max_properties` و `max_traits` یک payload را می‌بندند، نه اسکیمای شما را. می‌توانید ده هزار نام رویداد متمایز بسازید و هیچ‌چیز جلویتان را نمی‌گیرد. هیچ‌چیز هم مفیدشان نمی‌کند.
- **سقف سطر روی خروجی نیست.** مسیر `POST /v1/exports` سه فیلد `kind` و `format` و `spec` می‌گیرد، و نه `max_rows` دارد، نه سقف سمت سرور، و نه `expected_count` روی کار.
- **محدودیت نرخ به ازای IP هیچ‌جای محصول نیست.** نه روی میزبان ورود داده و نه روی میزبان مدیریتی.
- **بودجه گیرنده نیست.** هیچ‌چیز نمی‌شمارد که یک کلید در روز به چند نفر می‌تواند بفرستد، و برای همین `POST /v1/messages` یک واحد بودجه خرج دارد و با صد هزار فراخوانی می‌تواند به صد هزار گوشی برسد. خودتان بندش را بگذارید.
- **سقف پیام اعمال نمی‌شود.** بالاتر گفته شد.
- **نقطه import روی میزبان مدیریتی نیست.** وارد کردن CSV قابلیت پنل است، با سقف ۵۰۰۰۰۰ سطر و ۶۴ مگابایت، و API ندارد.
- **هدر `Accept-Language` خوانده نمی‌شود.** هیچ‌کدام از دو میزبان نمی‌خوانندش. متن سهمیه و قفل همیشه فارسی است.

مرتبط: [کدهای خطا](/docs/errors)، [API ورود داده](/docs/api/ingest)، [API مدیریتی](/docs/api/management).

---

# فایل OpenAPI

> توصیف ماشین‌خوان هر دو سطح، برای ساختن کلاینت، تست، و دادن به یک ابزار.
> https://segmentic.net/docs/openapi

هر دو سطح روبه‌مشتری در یک فایل ماشین‌خوان توصیف شده‌اند: ۴۱ مسیر، ۴۸ عملیات و ۶۵ اسکیما، با نسخهٔ `OpenAPI 3.1.1`.

## دانلود {#download}

```bash
curl -O https://segmentic.net/openapi.json
```

فایل با هر بیلد سایت از نو ساخته می‌شود، از همان مخزنی که خود API در آن است. یعنی نسخه‌ای که دانلود می‌کنید همیشه همان چیزی است که این صفحه‌ها توصیفش می‌کنند، نه یک کپی دستی که یک بار به‌روز شده و بعد جا مانده.

> [!note]
> فایل دومی با پسوند `.yaml` نداریم و لازم هم نیست. `JSON` زیرمجموعهٔ `YAML 1.2` است، پس هر ابزاری که `YAML` می‌خواهد همین فایل را بدون تغییر قبول می‌کند.

## چه چیزی داخلش هست {#what}

هر مسیری که یک مشتری صدا می‌زند، روی هر دو میزبان:

- میزبان ورود داده `https://in.segmentic.net`: رویداد، بچ، ثبت دستگاه، وب‌پوش، پیام‌رسان، صندوق پیام، پیام درون‌سایتی، وب‌هوک ورودی، و چهار مسیری که خودمان داخل پیام‌ها می‌گذاریم.
- میزبان مدیریتی `https://api.segmentic.net`: هویت کلید، توانمندی‌ها، شمای رویداد و ویژگی، مخاطب، سگمنت، کمپین، ورود داده سمت سرور، خروجی، گزارش قیف و ماندگاری، و پیام تراکنشی.

هر عملیات این‌ها را دارد: خلاصه، اسکیمای کامل درخواست و پاسخ با نام واقعی فیلدها، و همهٔ کدهای وضعیت با بدنهٔ خطایشان. توضیح را هم همه دارند جز `POST /v1/messenger/unlink`، و دست‌کم یک نمونهٔ واقعی که از تست‌های خود پروژه برداشته شده را هم همه دارند جز شش عملیاتی که پشت چهار مسیری‌اند که خودمان داخل پیام‌ها می‌گذاریم.

دو طرح احراز هویت تعریف شده و هیچ‌کدام پیش‌فرض کل سند نیستند، پس ۳۹ عملیات از ۴۸ عملیات دقیقا یکی را اعلام می‌کند:

| نام در فایل | چیست | کجا |
|---|---|---|
| `writeKey` | `http` با `scheme: bearer`. کلید نوشتن، `wk_seg_` به‌علاوهٔ ۴۳ کاراکتر | مسیرهای میزبان ورود داده |
| `managementKey` | `http` با `scheme: bearer`. کلید API، `sk_seg_` به‌علاوهٔ ۴۳ کاراکتر | مسیرهای میزبان مدیریتی |

نه عملیات دیگر `security: []` دارند: `GET /v1/status`، `POST /v1/hooks/{source}/{token}`، `POST /v1/bounce/{local}`، و شش عملیاتی که پشت چهار مسیری‌اند که خودمان داخل پیام‌ها می‌گذاریم.

سه شکل خطا هم در `components` هست، چون سرور واقعا سه شکل دارد: `IngestError` تخت با `status` و `message` روی میزبان ورود داده، و `PublicError` تودرتو و `FlatError` و `FlatCodedError` روی میزبان مدیریتی. هر پاسخ به آن یکی اشاره می‌کند که واقعا برمی‌گردد.

## دو میزبان در یک سند {#two-servers}

یک نکتهٔ ساختاری که اگر ندانید گیج‌کننده است.

هر دو میزبان مسیرهایی زیر پیشوند `/v1` دارند و یک سند `OpenAPI` کلیدهای `paths` را با رشته می‌سازد. پس هر دو سطح یک نقشهٔ `paths` مشترک دارند و **هر عملیات آرایهٔ `servers` خودش را با دقیقا یک عضو حمل می‌کند**، به‌علاوهٔ برچسبی که می‌گوید مال کدام سطح است.

تنها استثنا `GET /v1/status` است که واقعا روی هر دو میزبان وجود دارد، پس یک عملیات است با هر دو `server`.

اگر ابزارتان اولین `server` سند را برای همه‌چیز به کار می‌برد، نصف درخواست‌ها به میزبان اشتباه می‌روند. ابزارهایی که `servers` سطح عملیات را می‌خوانند (نسخه‌های امروزی `openapi-generator`، `Postman`، `Insomnia`، `Bruno`، `Kiota`) درست کار می‌کنند.

## ساختن کلاینت {#clients}

```bash title="کلاینت TypeScript"
npx @hey-api/openapi-ts -i https://segmentic.net/openapi.json -o src/segmentic
```

```bash title="کلاینت Go یا Python یا PHP"
npx @openapitools/openapi-generator-cli generate \
  -i https://segmentic.net/openapi.json \
  -g go \
  -o ./segmentic-client
```

کلاینتی که از این سند ساخته می‌شود دو کلاس احراز هویت دارد، چون سند دو تا دارد. کلید `wk_seg_` را به عملیات ورود داده بدهید و کلید `sk_seg_` را به عملیات مدیریتی. برعکسش کامپایل می‌شود و در زمان اجرا `401` می‌گیرد.

## دادن به یک ابزار {#tools}

`Postman` و `Insomnia` و `Bruno` هر سه از روی نشانی مستقیم ایمپورت می‌کنند: در واردکردن، گزینهٔ نشانی را بزنید و `https://segmentic.net/openapi.json` را بدهید. متغیرهای محیط را خودتان بسازید، چون کلید در سند نیست و نباید باشد.

برای تست قرارداد، `schemathesis` روی سطح ورود داده مستقیم کار می‌کند:

```bash
schemathesis run https://segmentic.net/openapi.json \
  --base-url https://in.segmentic.net \
  --header "Authorization: Bearer wk_seg_..."
```

> [!warn]
> این کار روی حساب واقعی رویداد واقعی می‌نویسد. یک اپ جدا با کلید جدا بسازید، وگرنه دادهٔ آزمایشی با دادهٔ واقعی شما قاطی می‌شود و برگرداندنش کار سختی است.

## چه چیزی داخلش نیست {#gaps}

- **API خود پنل.** آن سطح برای مشتری نیست، پایدار نیست، و هر هفته عوض می‌شود. آنچه اینجا هست همان چیزی است که قول پایداری‌اش داده شده.
- **هیچ مسیری برای ساختن کلید.** آن مسیرها روی شنوندهٔ دیگری‌اند که از بیرون آدرس‌پذیر نیست. کلید را از پنل بگیرید؛ در [راه‌اندازی سریع](/docs/quickstart) توضیح داده شده.
- **قاعده‌هایی که با تایپ بیان نمی‌شوند.** اینکه `context.screen` پذیرفته و هیچ‌جا ذخیره نمی‌شود، اینکه تایم‌استمپ قدیمی‌تر از سی روز روی `POST /v1/events` بی‌سروصدا به لبهٔ بازه چسبانده می‌شود، اینکه `format` ناشناخته در خروجی‌گرفتن به `ndjson` تبدیل می‌شود: این‌ها در `description` هر فیلد نوشته‌اند و هیچ اسکیمایی نمی‌تواند اجرایشان کند. کلاینت تولیدشده جلوی هیچ‌کدام را نمی‌گیرد.
- **محتوای `spec` در خروجی‌گرفتن.** `POST /v1/exports` این فیلد را دست‌نخورده رد می‌کند و هیچ‌چیز داخلش را اعتبارسنجی نمی‌کند. کلیدهایش برای هر `kind` فرق می‌کند و هیچ سندی در این مخزن آن‌ها را نشمرده است. این یک شکاف واقعی است، نه سهو در فایل.
- **صفحه‌بندی**، چون کار نمی‌کند و سند هم همین را می‌گوید. `limit` فقط روی `GET /v1/exports` خوانده می‌شود، `cursor` هیچ‌جا، و `has_more` همیشه `false` است. دو مسیر فهرست بی‌صدا روی ۲۰۰ سطر بریده می‌شوند، مرتب‌شده بر اساس تازگی ویرایش، و هیچ‌چیزی در پاسخ نمی‌گوید بقیه هم وجود دارند.

چهار مسیر هم داخل فایل هستند ولی شما صدایشان نمی‌زنید: پیکسل باز شدن، صفحهٔ لغو اشتراک، مرکز ترجیحات و ریدایرکت لینک کوتاه. این‌ها نشانی‌هایی‌اند که ما داخل پیام می‌گذاریم و یک نرم‌افزار ایمیل یا یک گوشی بازشان می‌کند. در فایل `security: []` دارند، یعنی «کلید نمی‌خواهد»، نه «باز است»: امضای داخل نشانی، خودش اعتبارنامه است. رفتارشان در [رضایت و سقف](/docs/consent) توضیح داده شده.

نکتهٔ صفحه‌بندی یکی از دو چیزی است که در توضیح `info` خود سند هم نوشته شده. دیگری این است که شکل خطا روی میزبان مدیریتی یکنواخت نیست: یازده مسیر از هندلرهای پنل استفاده می‌کنند و `{"error": "یک رشته"}` برمی‌گردانند نه پاکت کددار. کلاینتتان باید `error` را قبل از خواندن `error.code` هم رشته و هم شیء در نظر بگیرد. جزئیات در [کدهای خطا](/docs/errors).

## اگر API عوض شد {#changes}

قانون ۷ مخزن می‌گوید تغییر در این دو سطح، مستنداتش را در همان تغییر می‌برد، و یک بررسی در `CI` این را می‌سنجد: مسیری که ثبت شود و در مرجع نباشد بیلد را قرمز می‌کند، جز همان چهار نشانی داخل پیام که بالا آمد و چهار تای دیگر که اسکریپت اسمشان را می‌برد. جهت دیگر روی نمونهٔ کد سنجیده می‌شود نه روی نثر، چون صفحه اجازه دارد بگوید فلان مسیر وجود ندارد و چند صفحه هم به‌درستی همین را می‌گویند. قاعدهٔ دقیقش در [نسخه‌بندی و تغییرها](/docs/versioning) است. چون این فایل هم از همان مخزن ساخته می‌شود، نمی‌تواند از مرجع جا بماند. شرح کامل در [نسخه‌بندی و تغییرها](/docs/versioning).

---

# سرور MCP: وصل کردن سگمنتیک به یک عامل

> اتصال امن عامل‌های هوش مصنوعی به داده‌ها و ابزارهای سگمنتیک.
> https://segmentic.net/docs/mcp

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

سریع‌ترین راه همانی است که خودمان بالا نگه داشته‌ایم. کلاینتتان را به `https://mcp.segmentic.net/mcp` وصل کنید و کلید `sk_seg_` خودتان را در هدر `Authorization` بگذارید؛ چیزی نصب نمی‌کنید. تنظیمات کامل کلاینت در [میزبانی روی HTTP](/docs/mcp#hosted) آمده. اگر ترجیح می‌دهید این پروسه روی ماشین خودتان یا داخل شبکه خودتان بدود، از روی همین مخزن بیلدش می‌کنید و هر دو مسیر پایین‌تر نوشته شده‌اند.

> نمودار: مسیر دستیار هوش مصنوعی تا عملیات محدود به مجوز در سگمنتیک از راه MCP و API مدیریتی

## این چیست {#what-it-is}

دو فایل Go در `backend/cmd/segmentic-mcp`، خواندن در `main.go` و نوشتن در `write_tools.go`، روی هم حدود هزار خط، کنار ۶۶۶ خط تست. خودش را به کلاینت با نام `segmentic` و نسخهٔ `1.0.0` معرفی می‌کند و حداکثر بیست‌ویک ابزار ثبت می‌کند.

پروتکل را روی دو انتقال حرف می‌زند و انتخابش با شماست. **stdio** برای وقتی است که خودتان عامل را روی ماشین خودتان می‌دوانید: یک پروسه به‌ازای هر نفر و کلید از محیط. **HTTP** برای وقتی است که یک نفر آن را یک بار بالا می‌آورد و بقیه فقط یک نشانی می‌گیرند، و آنجا کلید هر کس در هدر `Authorization` خودش می‌آید.

هر ابزار یک درخواست HTTP به میزبان مدیریتی است، `https://api.segmentic.net`، با همان کلید `sk_seg_` که در یک دستور curl می‌گذاشتید.

## چرا کلاینت API است و نه اتصال به دیتابیس {#security}

توضیح بالای فایل، تمام استدلال امنیتی است و ارزش دارد پیش از تصمیم به دادن کلید به یک عامل خوانده شود:

```text
It is a client of the public REST API, not a second way into the database.
That is the whole security design: every permission gate, every tenant scope,
every budget already lives at the HTTP edge, and a tool here cannot reach
past them because it has no other door. An MCP server holding a database
handle would be a second admission path, and the second one is always the one
that forgets a check.
```

نتیجهٔ عملی: سرور MCP هیچ اجازه‌ای به عامل نمی‌دهد که کلید از قبل به یک حلقهٔ curl نداده باشد. اگر می‌خواهید بدانید یک عامل چه می‌تواند بکند، به جای این صفحه [مجوزهای کلید](/docs/api/management) را بخوانید. باطل‌کردن کلید عامل را همان لحظه متوقف می‌کند، چون ابزارها هیچ اعتبارنامهٔ دیگری ندارند.

نتیجهٔ معکوس همان است که معمولا از قلم می‌افتد. چون هر ابزار یک درخواست معمولی API است، عاملی که کلید دستش باشد به ابزارها محدود نیست. با curl می‌تواند هر مسیری را که کلید اجازه می‌دهد صدا بزند، از جمله مسیرهایی که سرور MCP برایشان هیچ ابزاری ندارد. فهرست ابزارها یک راحتی است، نه یک قفس.

## گرفتن فایل اجرایی {#install}

هیچ‌چیز منتشر نشده است. نه ریلیز گیت‌هاب هست، نه ایمیج مستقل، نه بستهٔ npm، نه فرمول Homebrew، نه نصاب، و نه لینک دانلودی در پنل. هیچ بیلد آمادهٔ مک یا ویندوزی هم هیچ‌جا وجود ندارد.

### خودتان بسازید {#build-it}

یک نسخه از مخزن بک‌اند لازم دارید و Go نسخهٔ `1.26.4` یا بالاتر.

```bash title="ساخت سرور MCP"
cd backend
go build -o segmentic-mcp ./cmd/segmentic-mcp
```

روی ویندوز خروجی را `segmentic-mcp.exe` نام بگذارید. برای ساختن نسخهٔ مک از روی یک دستگاه لینوکسی یا ویندوزی:

```bash title="بیلد متقاطع برای مک با تراشهٔ اپل"
cd backend
GOOS=darwin GOARCH=arm64 go build -o segmentic-mcp-darwin-arm64 ./cmd/segmentic-mcp
```

وابستگی‌ها vendor شده‌اند، پس بیلد برای ماژول‌ها به شبکه نیاز ندارد. خود زنجیرهٔ ابزار Go را لازم دارد، و `proxy.golang.org` به دانلود toolchain از آدرس‌های ایران ۴۰۳ می‌دهد؛ به همین دلیل داکرفایل خود مخزن `GOPROXY=off GOFLAGS=-mod=vendor` می‌گذارد. پیش از تلاش، toolchain را از یک آینه نصب کنید.

برای این فایل اجرایی هیچ هدفی در Makefile نیست. دستور `make build` آن را کامپایل می‌کند و خروجی را دور می‌ریزد، و به همین خاطر است که دستور بالا `-o` را صریح می‌دهد.

### اجرا از داخل ایمیج بک‌اند {#from-the-image}

ایمیج بک‌اند همهٔ پوشه‌های `cmd` را می‌سازد، پس `/usr/local/bin/segmentic-mcp` داخل `ghcr.io/segmentic1/segmentic-backend:latest` وجود دارد. با یک الگوی wildcard در داکرفایل آنجا رفته و نه از روی قصد، و یک بیلد استاتیک لینوکسی برای معماری `amd64` است، پس روی مک و ویندوز به‌صورت بومی اجرا نمی‌شود.

به‌عنوان فرمان MCP کار می‌کند، چون ایمیج فرمان پیش‌فرض ندارد و با `-i` ورودی و خروجی استاندارد رد می‌شود:

```bash title="اجرای سرور از روی ایمیج"
docker run -i --rm \
  -e SEGMENTIC_API_URL=https://api.segmentic.net \
  -e SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
  ghcr.io/segmentic1/segmentic-backend:latest segmentic-mcp
```

ایمیج خصوصی است. یک `docker login ghcr.io` با توکنی که اجازهٔ خواندنش را داشته باشد لازم دارید.

## کلید {#key}

سرور یک کلید API می‌خواهد، `sk_seg_...`. در پنل بسازیدش: **تنظیمات، اتصال‌ها و یکپارچه‌سازی، کلیدهای API**، یعنی `https://app.segmentic.net/fa/settings/keys`. ساختنش مجوز `apikey.write` روی حساب خودتان می‌خواهد.

فرم سه فیلد دارد: نام، نقش، و اینکه چند وقت معتبر بماند (۳۰ روز، ۹۰ روز یا یک سال، با پیش‌فرض یک سال). از این مسیر نمی‌شود کلید بدون انقضا ساخت.

متن کلید فقط یک بار نشان داده می‌شود، در پاسخ فراخوانی ساخت و در یک کادر روی صفحه. بعد از آن فقط یک هش `SHA-256` و یک پیشوند نمایشی ۱۴ نویسه‌ای باقی می‌ماند. اگر گمش کردید، یکی دیگر می‌سازید.

```json title="POST /v1/team/keys, 201"
{
  "id": 12,
  "name": "agent-readonly",
  "prefix": "sk_seg_ABCDEF",
  "role": "viewer",
  "role_label": "بیننده",
  "created_by": "Maryam",
  "created_at": "2026-08-07T18:00:00Z",
  "expires_at": "2027-08-07T18:00:00Z",
  "key": "sk_seg_yqk7..."
}
```

نقشی که انتخاب می‌کنید تعیین می‌کند چه ابزارهایی وجود داشته باشند، چون ابزارها از روی مجوزهای کلید ثبت می‌شوند. نقش مالک یکسره رد می‌شود، با کد `owner_key_forbidden`: هیچ کلیدی حق ندارد مالک باشد، چون پاک‌کردن حساب کاری نیست که یک یکپارچه‌سازی بتواند ساعت سه بامداد انجام بدهد. کلید برای نقشی بالاتر از نقش خودتان هم نمی‌توانید بسازید.

| نقشی که انتخاب می‌کنید | ابزارهایی که عامل می‌بیند | می‌نویسد | ارسال دارد |
|---|---|---|---|
| `viewer` (بیننده) | ۱۱ | ندارد | ندارد |
| `approver` (تأییدکننده) | ۱۱ | ندارد | ندارد |
| `analyst` (تحلیل‌گر) | ۱۳ | ندارد، ولی خروجی می‌گیرد | ندارد |
| `marketer` (بازاریاب) | ۲۱ | دارد | دارد |
| `admin` (مدیر) | ۲۱ | دارد | دارد |
| `finance` (مالی) | ۲، فقط `segmentic_whoami` و `segmentic_capabilities` | ندارد | ندارد |
| `owner` (مالک) | ساخته نمی‌شود | ــ | ــ |

این عددها در `cmd/segmentic-mcp/registration_test.go` تست دارند، پس اگر ابزاری اضافه یا جابه‌جا شود این جدول با بیلد قرمز می‌شود نه با شکایت یک مشتری.

تفاوت `analyst` با `viewer` دو ابزار خروجی است و همان دو تا تنها چیزی‌اند که نشانی ایمیل و شمارهٔ تلفن از ساختمان بیرون می‌برند. اگر عامل فقط قرار است بخواند و گزارش بدهد، `viewer` بدهید.

> [!warn]
> کلید نوشتن ندهید. کلید `wk_seg_` داخل باندل جاوااسکریپت و داخل اپ شما می‌رود، پس ذاتا عمومی است و فقط می‌تواند رویداد بنویسد. سرور با چنین کلیدی اصلا بالا نمی‌آید و پیش از فرستادن حتی یک درخواست همین را می‌گوید.

## کلید تصمیم می‌گیرد، نه یک فلگ {#read-only}

فهرست ابزارها از روی مجوزهای کلید ساخته می‌شود. کلیدی با نقش `viewer` یازده ابزار خواندنی می‌گیرد و بس. کلیدی با `segment.write` ابزارهای ساخت و ویرایش مخاطب را هم می‌گیرد. کلیدی با `campaign.send` دو ابزاری را می‌گیرد که به آدم واقعی می‌رسند.

نه فلگ `--allow-write` وجود دارد و نه `--allow-send`، و این عمدی است: تصمیم مال کسی است که حساب را دارد، نه مال یک خط فرمان که اپراتور می‌تواند غلط تایپش کند یا روشن جا بگذارد.

این بخش تا همین اواخر می‌گفت «پیش‌فرض فقط خواندن». آن جمله دربارهٔ فهرست ابزارها راست بود و دربارهٔ خود اعتبارنامه هیچ‌وقت راست نبود، و فرقشان همان چیزی است که این صفحه باید صریح بگوید:

> [!warn]
> مجوز `campaign.send` را فقط `marketer` و `admin` دارند، و هر دو `segment.write` و `campaign.write` و `journey.publish` و `template.write` و `profile.write` را هم دارند. پس **کلید ارسال‌دار محدودنشده، یک اعتبارنامهٔ کامل نوشتنی است**، چه سرور MCP برایش ابزار داشته باشد چه نداشته باشد: با curl همه‌اش در دسترس است. نداشتن ابزار، توانایی را نمی‌گرفت، فقط کار را به مسیری می‌برد که نه اعتبارسنجی آرگومان دارد، نه تأیید روی کارهای برگشت‌ناپذیر، و نه ردی از آنچه تلاش شده.

### کلید محدودشده، که حالا واقعا ساخته می‌شود {#scoped-keys}

راه‌حل درست، تنگ‌کردن خود کلید است نه پنهان‌کردن ابزار. ستون `scopes` روی `api_keys` از مهاجرت ۰۱۶ وجود داشت و مسیر خواندن هم همیشه آن را در اعتبارسنجی اعمال می‌کرد، ولی هیچ‌جا در آن نمی‌نوشت. حالا می‌نویسد:

```json title="POST /v1/team/keys"
{
  "name": "agent-authoring",
  "role": "marketer",
  "scopes": ["segment.read", "segment.write", "event.read"]
}
```

آن کلید مخاطب می‌سازد و ویرایش می‌کند و هیچ‌وقت نمی‌فرستد، نه از راه MCP و نه با curl. مجوز مؤثرش اشتراک نقش با این فهرست است، پس محدودکردن فقط می‌تواند **کم** کند: مجوزی که نقش ندارد را نمی‌شود با scope اضافه کرد و سرور ردش می‌کند با کد `scope_exceeds_role`.

| اگر بنویسید | چه می‌شود |
|---|---|
| فیلد `scopes` را ننویسید | کلید کل نقشش را دارد، همان رفتار همیشگی |
| فهرستی از مجوزهای داخل نقش | کلید فقط همان‌ها را دارد |
| فهرست خالی `[]` | رد می‌شود با `scopes_empty`. کلیدی که هیچ کاری نمی‌تواند بکند، از یک اشتباه قابل تشخیص نیست |
| مجوزی که وجود ندارد | رد می‌شود با `unknown_permission` و خود رشته در پیام می‌آید |
| مجوزی بیرون از نقش | رد می‌شود با `scope_exceeds_role` |

مقدار `scoped` در `segmentic_whoami` می‌گوید کلید تنگ شده یا نه، و فهرست `permissions` همان مجموعهٔ مؤثر است، پس عامل از اول می‌داند چه می‌تواند بکند.

فرم پنل هنوز فیلد انتخاب مجوز ندارد و کلید محدودشده فعلا از API ساخته می‌شود. تا وقتی آن فرم بیاید، ساده‌ترین راه یک `curl` با نشست خودتان است.

## پیکربندی {#configure}

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

| فلگ | متغیر محیطی | پیش‌فرض | چیست |
|---|---|---|---|
| `-api` | `SEGMENTIC_API_URL` | `http://localhost:8082` | آدرس پایهٔ API مدیریتی. اسلش انتهایی حذف می‌شود. |
| `-key` | `SEGMENTIC_API_KEY` | ندارد | کلید `sk_seg_`. نبودنش کشنده است. |
| `-timeout` | ندارد | `30s` | مهلت هر درخواست HTTP. برای این یکی متغیر محیطی وجود ندارد. |

به جای `-key` از متغیر محیطی استفاده کنید. مقدار یک فلگ در جدول فرایندها می‌نشیند، جایی که هر فرایند دیگری روی همان دستگاه می‌تواند بخواندش.

اگر گزارش می‌گیرید `-timeout` را روی `60s` بگذارید، و بدانید که این فقط نصف راه‌حل است. هندلرهای گزارش برای خودشان ۴۵ ثانیه وقت می‌گذارند، ولی `WRITE_TIMEOUT` خود سرور HTTP پیش‌فرض ۳۰ ثانیه است و کلاینت هم پیش‌فرض ۳۰ ثانیه، پس یک کوئری ماندگاری سنگین از دو طرف قطع می‌شود در حالی که سمت سرور همچنان می‌دود و ۲۵ واحد بودجه هم خرج شده است. بالا بردن مهلت کلاینت وقتی کمک می‌کند که هرکس آن نصب را می‌گرداند `WRITE_TIMEOUT` را هم بالا ببرد.

### کلود کد {#claude-code}

فایل پروژه `.mcp.json` در ریشهٔ مخزن شما:

```json title=".mcp.json"
{
  "mcpServers": {
    "segmentic": {
      "command": "/absolute/path/to/segmentic-mcp",
      "args": ["-timeout", "60s"],
      "env": {
        "SEGMENTIC_API_URL": "https://api.segmentic.net",
        "SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
      }
    }
  }
}
```

کلیدی که زیر `mcpServers` می‌گذارید فضای نام می‌شود، پس ابزارها به شکل `mcp__segmentic__segmentic_whoami` و مانند آن ظاهر می‌شوند. همان `segmentic` را نگه دارید تا با نامی که خود سرور اعلام می‌کند یکی باشد.

همین کار از خط فرمان:

```bash title="claude mcp add"
claude mcp add segmentic --scope project \
  --env SEGMENTIC_API_URL=https://api.segmentic.net \
  --env SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
  -- /absolute/path/to/segmentic-mcp -timeout 60s
```

هرچه بعد از `--` بیاید فرمان و آرگومان‌های خودش است، پس `-timeout` به فایل اجرایی می‌رسد و نه به CLI.

برای اجرا از روی ایمیج به جای فایل محلی:

```json title=".mcp.json، از روی ایمیج"
{
  "mcpServers": {
    "segmentic": {
      "command": "docker",
      "args": [
        "run", "-i", "--rm",
        "-e", "SEGMENTIC_API_URL",
        "-e", "SEGMENTIC_API_KEY",
        "ghcr.io/segmentic1/segmentic-backend:latest",
        "segmentic-mcp"
      ],
      "env": {
        "SEGMENTIC_API_URL": "https://api.segmentic.net",
        "SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
      }
    }
  }
}
```

### کلود دسکتاپ {#claude-desktop}

روی مک `~/Library/Application Support/Claude/claude_desktop_config.json` و روی ویندوز `%APPDATA%\Claude\claude_desktop_config.json`.

```json title="claude_desktop_config.json"
{
  "mcpServers": {
    "segmentic": {
      "command": "/absolute/path/to/segmentic-mcp",
      "args": ["-timeout", "60s"],
      "env": {
        "SEGMENTIC_API_URL": "https://api.segmentic.net",
        "SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
      }
    }
  }
}
```

روی ویندوز مسیر مطلق است و بک‌اسلش‌ها دوتا می‌شوند:

```json title="claude_desktop_config.json روی ویندوز"
{
  "mcpServers": {
    "segmentic": {
      "command": "C:\\Program Files\\Segmentic\\segmentic-mcp.exe",
      "args": [],
      "env": {
        "SEGMENTIC_API_URL": "https://api.segmentic.net",
        "SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
      }
    }
  }
}
```

کلود دسکتاپ `PATH` پوستهٔ شما را به ارث نمی‌برد، پس `command` باید مسیر مطلق باشد.

### کدکس {#codex}

فایل `~/.codex/config.toml`:

```text title="~/.codex/config.toml"
[mcp_servers.segmentic]
command = "/absolute/path/to/segmentic-mcp"
args = ["-timeout", "60s"]

[mcp_servers.segmentic.env]
SEGMENTIC_API_URL = "https://api.segmentic.net"
SEGMENTIC_API_KEY = "sk_seg_REPLACE_ME"
```

### کرسر {#cursor}

فایل `.cursor/mcp.json` در پروژه، یا `~/.cursor/mcp.json` به‌صورت سراسری.

```json title=".cursor/mcp.json"
{
  "mcpServers": {
    "segmentic": {
      "command": "/absolute/path/to/segmentic-mcp",
      "args": ["-timeout", "60s"],
      "env": {
        "SEGMENTIC_API_URL": "https://api.segmentic.net",
        "SEGMENTIC_API_KEY": "sk_seg_REPLACE_ME"
      }
    }
  }
}
```

### میزبان‌شده روی HTTP {#hosted}

اگر یک نفر سرور را یک بار بالا بیاورد، بقیه چیزی نصب نمی‌کنند:

```bash
segmentic-mcp -http :8090 -api https://api.segmentic.net
```

`-http` و `-key` با هم جمع نمی‌شوند و اجرا با هر دو رد می‌شود. دلیلش طراحی است نه
سخت‌گیری: یک پروسه به چند حساب خدمت می‌دهد، پس کلید باید در هر درخواست بیاید و
فهرست ابزار از مجوزهای **همان** کلید ساخته شود. `-key` روی سرور میزبان‌شده یعنی
اعتبارنامه یک حساب به‌جای همه جواب می‌دهد.

سمت کلاینت، به‌جای دستور، یک نشانی و یک کلید:

```json title="نمونه پیکربندی کلاینت"
{
  "mcpServers": {
    "segmentic": {
      "url": "https://mcp.segmentic.net/mcp",
      "headers": { "Authorization": "Bearer sk_seg_..." }
    }
  }
}
```

چهار چیزی که این حالت را قابل میزبانی می‌کند و ارزش گفتن دارد:

- **این پروسه هیچ اعتبارنامه‌ای از خودش ندارد.** چیزی در آن نیست که دزدیده شود.
- **کلید فقط از هدر خوانده می‌شود، هرگز از کوئری‌استرینگ.** کلید در URL در هر لاگ
  دسترسی و هر هدر referrer می‌نشیند.
- **کلید SDK رد می‌شود.** کلید `wk_` در باندل جاوااسکریپت هر مشتری هست، پس پذیرفتنش
  یعنی می‌شود عاملی را به اعتبارنامه‌ای که از سورس یک صفحه برداشته شده وصل کرد.
- **مجوز در هر فراخوان سمت API اعمال می‌شود.** هویت کلید حدود دو دقیقه کش می‌شود و
  آن کش فقط تعیین می‌کند چه ابزارهایی **فهرست** شوند؛ کلید باطل‌شده در همان فراخوان
  بعدی جواب ۴۰۱ می‌گیرد.

`GET /healthz` هم هست و عمدا پشت کلید نیست: پروبی که اعتبارنامه بخواهد، پروبی است
که کسی خاموشش می‌کند.

### توسعهٔ محلی {#local}

آدرس پایهٔ پیش‌فرض `http://localhost:8082` است، همان پورتی که API عمومی روی آن گوش می‌دهد، پس در حالت محلی می‌شود ننویسیدش:

```json title=".mcp.json روی استک محلی"
{
  "mcpServers": {
    "segmentic-local": {
      "command": "/absolute/path/to/segmentic-mcp",
      "env": { "SEGMENTIC_API_KEY": "sk_seg_LOCAL_KEY" }
    }
  }
}
```

> [!warn]
> مقدار `PUBLIC_API_ADDR` پیش‌فرض خالی است، و خالی یعنی API عمومی اصلا سرو نمی‌شود. نصبی که تصمیم نگرفته چیزی را در معرض بگذارد، نباید در معرض بگذارد. اگر API را بدون تنظیم این متغیر بالا بیاورید، چیزی روی `8082` گوش نمی‌دهد و سرور MCP با پیام connection refused بیرون می‌آید. پورت خود داشبورد، یعنی `8081`، جایگزین نیست: یک mux دیگر است و مسیر `/v1/whoami` را به این شکل ندارد.

## بررسی نصب بدون هیچ عاملی {#verify}

سرور JSON-RPC را روی stdio حرف می‌زند، پس بدون هیچ کلاینتی می‌شود کلید را ثابت کرد و تعداد ابزارها را دید. در یک ترمینال اجرایش کنید:

```bash title="اجرای دستی"
SEGMENTIC_API_KEY=sk_seg_REPLACE_ME \
SEGMENTIC_API_URL=https://api.segmentic.net \
./segmentic-mcp
```

پیش از ثبت هر ابزاری `GET /v1/whoami` را صدا می‌زند، بعد یک خط روی خروجی خطای استاندارد می‌نویسد و منتظر کلاینت روی ورودی استاندارد می‌ماند:

```text
2026/08/07 21:04:11 segmentic-mcp 1.0.0, tenant 7, role viewer, 8 permissions
```

شکست‌خوردن در لحظهٔ بالا آمدن به جای اولین فراخوانی ابزار عمدی است: عاملی که وسط یک گفتگو می‌فهمد اعتبارنامه‌اش غلط است، این را «ابزار خراب است» گزارش می‌کند و آدم هیچ‌وقت دلیل واقعی را نمی‌بیند.

کلید بد با کد ۱ بیرون می‌آید و یکی از این‌ها را روی خروجی خطا می‌نویسد:

```text
segmentic-mcp: no API key: set SEGMENTIC_API_KEY or pass -key
```

```text
segmentic-mcp: that is an SDK write key (wk_…), which cannot read anything.
This needs a management key (sk_seg_…) from Settings → API keys.
```

```text
segmentic-mcp: could not reach the Segmentic API: unauthenticated: a valid API key is required
```

نگهبان کلید نوشتن روی پیشوند `wk_` عمل می‌کند، پیش از آنکه هیچ درخواستی بیرون برود. توضیح کد می‌گوید چرا پیام جداست و ۴۰۱ نیست: کلید نوشتن داخل باندل جاوااسکریپت هر مشتری می‌رود، پس پذیرفتنش یعنی می‌شود یک عامل را به اعتبارنامه‌ای وصل کرد که از سورس یک صفحه برداشته شده، و شکست همین حالا با جمله‌ای که فرق را توضیح می‌دهد بهتر از شکست بعدی با ۴۰۱ است.

## ابزارها {#tools}

مجموعه دقیق به مجوزهای کلید بستگی دارد. هر زیربخش مجوزی که ابزار را باز می‌کند، آرگومان‌ها، مسیری که صدا می‌زند، بودجه‌ای که خرج می‌کند و چیزی که برمی‌گردد را می‌گوید.

نتیجه همیشه یک بلوک متنی است که داخلش JSON با تورفتگی نشسته. خروجی ساختاریافته وجود ندارد، پس کلاینتی که دنبال `structuredContent` بگردد چیزی پیدا نمی‌کند. شکست‌ها به‌صورت محتوای ابزار با `isError: true` برمی‌گردند و نه به‌صورت خطای پروتکل، تا مدل دلیل را بخواند و بتواند کاری بکند.

| ابزار | مجوز | مسیر | بودجه |
|---|---|---|---|
| `segmentic_whoami` | ندارد | هیچ، در زمان بالا آمدن کش شده | ۰ |
| `segmentic_capabilities` | ندارد | `GET /v1/capabilities` | ۱ |
| `segmentic_get_audience` | `segment.read` | `GET /v1/segments/{id}` | ۱ |
| `segmentic_create_audience` | `segment.write` | `POST /v1/segments` | ۱ |
| `segmentic_update_audience` | `segment.write` | `PUT /v1/segments/{id}` | ۱ |
| `segmentic_delete_audience` | `segment.delete` | `GET` سپس `DELETE /v1/segments/{id}` | ۲ |
| `segmentic_create_campaign` | `campaign.write` | `POST /v1/campaigns` | ۱ |
| `segmentic_submit_campaign_for_approval` | `campaign.write` | `POST /v1/campaigns/{id}/submit` | ۱ |
| `segmentic_set_campaign_recurrence` | `campaign.send` | `GET` سپس `PUT /v1/campaigns/{id}/recurrence` | ۶ |
| `segmentic_clear_campaign_recurrence` | `campaign.send` | `DELETE /v1/campaigns/{id}/recurrence` | ۱ |
| `segmentic_send_campaign` | `campaign.send` | `GET` سپس `POST /v1/campaigns/{id}/send` | ۶ |
| `segmentic_ingest_events` | `profile.write` | `POST /v1/events` | ۱۰ |
| `segmentic_queue_export` | `data.export` | `POST /v1/exports` | ۲۵ |
| `segmentic_list_exports` | `data.export` | `GET /v1/exports` | ۱ |
| `segmentic_describe_data` | `event.read` | `GET /v1/schema/events` و `GET /v1/schema/traits` | ۱۰ |
| `segmentic_ingest_quality` | `event.read` | `GET /v1/ingest/quality` | ۵ |
| `segmentic_list_audiences` | `segment.read` | `GET /v1/segments` | ۱ |
| `segmentic_describe_audience` | `segment.read` | `POST /v1/audiences/validate` | ۱ |
| `segmentic_count_audience` | `segment.read` | `POST /v1/audiences/count` | ۲۵ |
| `segmentic_list_campaigns` | `campaign.read` | `GET /v1/campaigns` | ۱ |
| `segmentic_campaign_report` | `campaign.read` | `GET /v1/campaigns/{id}` | ۵ |
| `segmentic_funnel_report` | `analytics.read` | `POST /v1/reports/funnel` | ۲۵ |
| `segmentic_retention_report` | `analytics.read` | `POST /v1/reports/retention` | ۲۵ |
| `segmentic_send_transactional_message` | `campaign.send` | `POST /v1/messages` | ۱ |

### segmentic_whoami {#whoami}

همیشه ثبت می‌شود، حتی برای کلیدی که هیچ مجوزی ندارد.

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

```json title="چه برمی‌گرداند"
{
  "tenant_id": 7,
  "role": "analyst",
  "permissions": [
    "analytics.read", "audit.read", "campaign.read", "data.export",
    "event.read", "journey.read", "member.read", "profile.read",
    "segment.read", "settings.read", "template.read"
  ],
  "scoped": false
}
```

فیلد `permissions` مجموعهٔ مؤثر است و مرتب. یازده‌تاست و نه چهارتا، چون نقش تحلیل‌گر همه‌چیز را می‌خواند: مجموعه‌اش را در `backend/internal/auth/role.go` ببینید. ابزارهایی که می‌بینید کمتر از این‌اند، چون سرور MCP فقط برای بخشی از این مجوزها ابزار نوشته. فیلد `scoped` قرار است بگوید کلید پایین‌تر از نقشش تنگ شده، ولی روی هر کلیدی که این محصول می‌تواند بسازد `false` است: ستون محدودسازی روی `api_keys` هست و هیچ مسیری در آن نمی‌نویسد. نه نشانی ایمیل، نه نام آدم، نه شناسهٔ حساب، و نه شناسهٔ کلید: API خودش `api_key_id` را برمی‌گرداند و این ابزار دورش می‌ریزد.

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

### segmentic_describe_data {#describe-data}

مجوز `event.read`. آرگومان ندارد. دو GET پشت سر هم، پس هر فراخوانی ۱۰ واحد بودجه می‌برد؛ اگر اولی شکست بخورد دومی اصلا زده نمی‌شود و کل ابزار خطا می‌دهد.

این ابزار به‌خاطر گران‌ترین اشتباهی وجود دارد که یک عامل اینجا می‌کند. از خود فایل:

```text
It invents identifiers. describe_data exists so a segment id or an event
name can be looked up rather than guessed; a guessed event name compiles
cleanly and returns an empty audience, which reads as "nobody matches"
rather than as "that event does not exist".
```

دو پاسخ سرور یک لایه عمیق‌تر از بدنهٔ خودشان تو در تو می‌نشینند:

```json title="چه برمی‌گرداند"
{
  "events": {
    "events": [
      {
        "name": "order_completed",
        "volume": 184203,
        "prop_keys": ["revenue", "order_id", "currency"],
        "last_seen": "2026-08-06"
      }
    ]
  },
  "traits": {
    "traits": ["city", "order_count", "plan"],
    "schema": [
      {"name": "city", "kind": "string", "users": 91204},
      {"name": "order_count", "kind": "number", "users": 58110}
    ]
  }
}
```

ستونی که واقعا باید خواند `last_seen` است. رویدادی با حجم بزرگ و تاریخ آخرین دیده‌شدنی مال سه هفته پیش، یعنی یکپارچه‌سازی‌ای که خراب شده، و هیچ عدد دیگری این را نمی‌گوید، چون حجم به‌تنهایی تا یک ماه بعدش هم سالم به نظر می‌رسد: پنجره نود روزه است.

فیلدهای `prop_keys` و `last_seen` وقتی خالی باشند حذف می‌شوند. مقدار `kind` یا `string` است یا `number`؛ ویژگی‌ای که هر دو شکل فرستاده شده یک بار و به‌صورت `number` می‌آید. عدد `users` تعداد پرونده‌هایی است که آن ویژگی را دارند. توضیح خود ابزار برای ویژگی‌ها هم «با حجم» می‌گوید که دقیق نیست: سطرهای ویژگی شمار پرونده دارند، نه حجم رویداد.

حسابی که هیچ‌چیز نفرستاده باشد `{"events":[]}` و `{"traits":[]}` می‌گیرد.

### segmentic_list_audiences {#list-audiences}

مجوز `segment.read`. هزینه ۱.

برخلاف نامش، این ابزار سگمنت‌های ذخیره‌شده را برمی‌گرداند، همان چیزی که پنل به آن سگمنت می‌گوید. مسیر `GET /v1/segments` را صدا می‌زند که فقط وقتی ثبت شده که آن نصب سگمنت را سرو کند.

آرگومانی نمی‌گیرد.

> [!warn]
> چیزی که برمی‌گردد همهٔ سگمنت‌ها نیست. کوئری خود انبار `ORDER BY updated_at DESC LIMIT 200` است، پس ۲۰۰ سگمنتی که تازه‌تر از همه به‌روز شده‌اند برمی‌گردند و بس. سقف بی‌صداست: نه شماری در پاسخ هست، نه `has_more`، نه هشداری، و حسابی که ۲۵۰ سگمنت دارد روی هیچ سطحی راهی به آن ۵۰ تای دیگر ندارد. توضیح خود ابزار همین را به مدل می‌گوید، با این نتیجه‌گیری که روی حساب بزرگ، نامی که اینجا پیدا نمی‌کنید را ناشناخته بگیرید و نه ناموجود. ۲۰۰ درخت فیلتر کامل هنوز می‌تواند بزرگ باشد؛ اگر از سقف ۴ مبی‌بایتی پاسخ در کلاینت رد شود، ابزار `unreadable response (200)` را با ۲۰۰ نویسهٔ اول گزارش می‌کند.
>
> این ابزار تا همین اواخر دو آرگومان `limit` و `cursor` می‌گرفت که هیچ کاری نمی‌کردند: هندلر کوئری‌استرینگ را نمی‌خواند و هیچ پاسخی `next_cursor` ندارد. آرگومانی که کاری نمی‌کند از نبودنش بدتر است، چون مدل باورش می‌کند و صفحهٔ دوم را می‌خواهد. هر دو برداشته شدند.

```json title="چه برمی‌گرداند"
{
  "segments": [
    {
      "id": 41,
      "name": "خریداران اخیر",
      "kind": "dynamic",
      "definition": {
        "version": 1,
        "root": {
          "kind": "event",
          "event": "order_completed",
          "window": {"kind": "last", "amount": 30, "unit": "day"}
        }
      },
      "description_fa": "کاربرانی که شهرشان تهران است",
      "last_size": 18422,
      "last_computed_at": "2026-08-06T21:00:00Z",
      "updated_at": "2026-08-01T11:12:00Z"
    }
  ]
}
```

مقدار `kind` یکی از `dynamic` و `static` و `realtime` است. فیلد `definition` کل درخت فیلتر است، نه خلاصه‌اش. فیلد `description_fa` همیشه فارسی است، در هر پاسخی از این API: مسیرهای عمومی میان‌افزار زبان ندارند، پس `Accept-Language` چیزی را عوض نمی‌کند و سرور MCP هم اصلا چنین هدری نمی‌فرستد.

### segmentic_describe_audience {#describe-audience}

مجوز `segment.read`. هزینه ۱. مسیر `POST /v1/audiences/validate`.

یک آرگومان اجباری دارد، `filter`، و باید یک تعریف کامل باشد یعنی `{"version": 1, "root": {...}}` و نه یک شرط تنها. توضیح ابزار آن را به‌عنوان تمرین ارزان پیش از شمارش گران معرفی می‌کند: هیچ کوئری نمی‌زند، هیچ‌چیز دربارهٔ آدم‌ها برنمی‌گرداند، و در یک جمله می‌گوید فیلتر چه می‌گوید.

```json title="آرگومان‌ها"
{
  "filter": {
    "version": 1,
    "root": {"kind": "trait", "trait": "city", "operator": "eq",
             "value": {"type": "string", "str": "تهران"}}
  }
}
```

```json title="پاسخ موفق"
{
  "valid": true,
  "description_fa": "کاربرانی که شهرشان تهران است"
}
```

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

فیلتر نامعتبر ۴۲۲ می‌گیرد، نه ۲۰۰ با `valid: false`. داشبورد ۲۰۰ با `valid:false` می‌دهد که برای فرمی که همان لحظه تایپ می‌شود درست است و برای یکپارچه‌سازی‌ای که مدیریت خطایش روی وضعیت شاخه می‌زند غلط.

```json title="فیلتر نامعتبر"
{"error": {"code": "filter_invalid", "message": "segment: unknown node kind"}}
```

این یکی را مدل کامل می‌بیند، به‌صورت `filter_invalid: segment: unknown node kind`.

### segmentic_count_audience {#count-audience}

مجوز `segment.read`. هزینه ۲۵، سنگین‌ترین وزنی که هست. مسیر `POST /v1/audiences/count` با همان آرگومان `filter` بالا.

```json title="پاسخ موفق"
{
  "count": 18422,
  "approximate": false,
  "description": "کاربرانی که شهرشان تهران است",
  "took_ms": 812
}
```

یک عدد، یک جملهٔ فارسی و یک مدت‌زمان. هیچ‌چیز دیگر، و توضیح ابزار این را با حروف بزرگ می‌گوید تا مدل دنبال فهرست آدم‌ها نگردد.

با بودجهٔ پیش‌فرض ۶۰۰ واحد برای هر کلید در هر دقیقه، این یعنی ۲۴ شمارش در دقیقه تا وقتی API جواب `budget_exhausted` بدهد. همین سقف تنها ترمز است. نه شرط رویدادی بدون پنجرهٔ زمانی رد می‌شود و نه سقف هم‌زمانی برای شمارش وجود دارد.

### segmentic_list_campaigns {#list-campaigns}

مجوز `campaign.read`. هزینه ۱. مسیر `GET /v1/campaigns`.

آرگومانی نمی‌گیرد. دقیقا مثل `segmentic_list_audiences` کوئری انبار `LIMIT 200` ثابت دارد: ۲۰۰ کمپین تازه‌تر، بدون `next_cursor` و بدون هیچ نشانه‌ای در پاسخ از اینکه کمپین دویست‌ویکمی هم هست. توضیح ابزار همین سقف را صریح به مدل می‌گوید. پیش از این، توضیح ادعا می‌کرد «صفحه‌بندی‌شده؛ اندازهٔ صفحه را سرور محدود می‌کند» و دو آرگومان بی‌اثر هم می‌گرفت؛ نیمهٔ دوم آن جمله درست بود و نیمهٔ اولش نه.

```json title="چه برمی‌گرداند"
{
  "campaigns": [
    {
      "id": 812,
      "name": "بازگشت مشتری تیر",
      "channel": "sms",
      "status": "finished",
      "estimated": 60000,
      "processed": 60000,
      "sent": 41230,
      "scheduled_at": "2026-07-14T06:30:00Z",
      "updated_at": "2026-07-14T09:11:00Z"
    }
  ]
}
```

هیچ شناسه‌ای از گیرنده‌ها، از هیچ نوع.

### segmentic_campaign_report {#campaign-report}

مجوز `campaign.read`. هزینه ۵. مسیر `GET /v1/campaigns/{id}` با یک آرگومان اجباری، `campaign_id`، که توضیح اسکیمایش به مدل می‌گوید آن را از `segmentic_list_campaigns` بردارد و از خودش نسازد.

پاسخ چند بخش دارد: خود کمپین با نسخه‌هایش، پیشرفت، تفکیک رسیدن به مخاطب، تفکیک تحویل، تعامل به تفکیک کانال، و اثر افزوده در برابر گروه کنترل.

```json title="تعامل و اثر افزوده، بخش‌هایی که دقت می‌خواهند"
{
  "engagement": [
    {
      "channel": "sms",
      "channel_fa": "پیامک",
      "issued": 41230,
      "withheld": 180,
      "measurable_open": 0,
      "measurable_click": 41230,
      "opened": 0,
      "clicked": 5120,
      "opened_unmeasurable": 41230,
      "clicked_unmeasurable": 0,
      "why_open": "پیامک رسید خواندن ندارد"
    }
  ],
  "uplift": {
    "verdict": "positive",
    "goal": "order_completed",
    "treated_users": 41230,
    "treated_conversions": 2110,
    "control_users": 4581,
    "control_conversions": 190,
    "lift": 0.117,
    "lift_low": 0.041,
    "lift_high": 0.194,
    "extra": 1420,
    "extra_low": 812,
    "extra_high": 2030,
    "money_known": true,
    "computed_at": "2026-07-21T09:30:00Z"
  }
}
```

هر نرخی با صورت کسر و یک مخرج نام‌دار می‌آید. عدد `opened` بدون `measurable_open` بی‌معنی است، و برای پیامک `measurable_open` صفر است، چون پیامک رسید خواندن ندارد. کمپینی که پیامش لینک نداشته، کمپین با نرخ کلیک صفر نیست. توضیح ابزار دقیقا به همین دلیل به مدل می‌گوید هر دو عدد را نقل کند و هرگز آن‌ها را در یک درصد جمع نکند.

عدد `lift` به‌تنهایی یک اندازه‌گیری نیست، وسط یک بازه است. عددهای `lift_low` و `lift_high` آن بازه‌اند، و گزارشی که فقط تخمین نقطه‌ای را نقل کند تنها چیزی را دور ریخته که می‌گوید کمپین کار کرده یا نه.

کمپین تمام‌شده‌ای که پنجرهٔ سنجش هفت‌روزه‌اش هنوز بسته نشده، بخش اثر افزوده را با `verdict: "too_early"` می‌گیرد و بقیهٔ فیلدها تقریبا خالی‌اند. این عمدا ساخته می‌شود، چون در غیر این صورت آن بخش بعد از هر ارسال یک هفته غیب می‌شد و مثل یک قابلیت جاافتاده خوانده می‌شد، نه مثل «هنوز داریم می‌شماریم».

توضیح ابزار به یکی از این‌ها می‌گوید «حکم A/B». این دقیق نیست. مقدار `uplift.verdict` حکم گروه کنترل است، تیمارشده در برابر کنترل. نسخه‌ها داخل `campaign.variants` هستند، ولی در این پاسخ مقایسهٔ تبدیل بین نسخه‌ها وجود ندارد و ابزار جداگانه‌ای برای آزمایش هم نیست.

شناسهٔ ناموجود و شناسهٔ حساب دیگری هر دو ۴۰۴ می‌گیرند، که مدل آن را به‌صورت خشک `http 404` می‌بیند.

### segmentic_funnel_report {#funnel-report}

مجوز `analytics.read`. هزینه ۲۵. پشت قفل مالی است، پس حسابی که از سقفش گذشته باشد اینجا هم مثل پنل با `account_locked` رد می‌شود.

آرگومان‌هایش `steps` (آرایه‌ای از گام‌ها)، `from` و `to` (هر دو `RFC3339` و اجباری)، `window` (اجباری) و `strict` (اختیاری) هستند. سرور بین ۲ تا ۱۲ گام می‌خواهد و بازه‌ای حداکثر ۷۳۰ روزه.

`window` یعنی هرکس چقدر وقت دارد تا کل قیف را تمام کند، به‌صورت رشتهٔ مدت مثل `7d` یا `24h` یا `30m`. سرور پیش‌فرضی برایش ندارد و بدون آن درخواست را رد می‌کند، به این دلیل ساده که قیفی که در سی روز سنجیده شود و همان قیف در یک ساعت، دو سؤال متفاوت‌اند. مقدارش نباید بلندتر از خود بازه باشد.

`strict` یعنی گام‌ها باید بدون هیچ رویدادی در میانه پشت سر هم بیایند. پیش‌فرض خاموش است، که معنای معمول «چه کسی محصولی را دید و خرید» همان است.

> [!warn]
> این ابزار تا همین اواخر اصلا نمی‌توانست موفق شود: ساختار آرگومان‌هایش فیلد `window` نداشت، پس بدنه هیچ‌وقت آن را حمل نمی‌کرد و سرور هر بار رد می‌کرد. مدل فقط `http 400` را می‌دید، بدون هیچ پیامی، بعد از اینکه ۲۵ واحد بودجه خرج شده بود. اگر نسخهٔ کامپایل‌شده‌تان قدیمی است، از مخزن دوباره بسازید؛ راه دیگر گرفتن قیف مستقیم با `POST /v1/reports/funnel` است که در [گزارش و خروجی](/docs/reports) توضیح داده شده.

### segmentic_retention_report {#retention-report}

مجوز `analytics.read`. هزینه ۲۵. پشت همان قفل مالی. مسیر `POST /v1/reports/retention`.

آرگومان‌هایش `from` و `to` (اجباری) و چهار اختیاری‌اند: `start` و `return` و `granularity` و `periods`.

`start` و `return` از هم جدایند چون «برگشت» به‌ندرت یعنی «همان کار را دوباره کرد». یک فروشگاه می‌خواهد بداند چه کسی ثبت‌نام کرد و بعد **خرید کرد**؛ اینکه بپرسد اپ را باز کرد یا نه، عدد را زیبا می‌کند و به هیچ سؤالی جواب نمی‌دهد. هر کدام را خالی بگذارید یعنی «هر فعالیتی».

`granularity` یکی از `day` و `week` و `month` است. هفته شنبه شروع می‌شود و ماه جلالی است. پیش‌فرض `day`.

> [!warn]
> این ابزار تا همین اواخر به سؤالی غیر از سؤالی که از آن پرسیده شده جواب می‌داد، و خطا هم نمی‌داد: فیلد `event` می‌فرستاد، درخواست ماندگاری در سرور فیلدهای `start` و `return` دارد و فیلد ناشناخته را به جای رد کردن نادیده می‌گیرد. پس هر رویدادی که مدل نام می‌برد بی‌صدا دور ریخته می‌شد و هر فراخوان «هر فعالیتی، بعد هر فعالیتی دوباره» را می‌سنجید. `granularity` و `periods` را هم هرگز نمی‌فرستاد. جواب باورپذیر ولی غلط از خطا بدتر است. اگر نسخهٔ کامپایل‌شده‌تان قدیمی است، از مخزن دوباره بسازید.

### segmentic_send_transactional_message {#send-message}

مجوز `campaign.send`. هزینه ۱ از بودجهٔ درخواست. مسیر `POST /v1/messages` که فقط وقتی ثبت است که آن نصب مسیر ارسال داشته باشد.

این تنها ابزاری است که به یک آدم می‌رسد. توضیحش با همین شروع می‌شود، با حروف بزرگ، و بلافاصله جایگزین درست را نام می‌برد:

```text
Send ONE message to ONE named person, immediately, an order update, a delivery
notice, a login code. THIS REACHES A REAL PERSON'S PHONE OR INBOX AND CANNOT BE
RECALLED. Marketing is refused: use a campaign, which a human schedules. The
idempotency_key must identify the real-world event (for example
'order-8821-shipped'), so that retrying is safe; never invent a random one,
because a fresh key on a retry sends a second message.
```

| آرگومان | اجباری | توضیح |
|---|---|---|
| `user_id` | بله | همان آدم، با شناسه‌ای که اپش گزارش می‌کند |
| `channel` | بله | هشدار پایین را ببینید |
| `template_id` | بله | یک قالب ذخیره‌شده. متن پیام را نمی‌شود درجا فرستاد |
| `idempotency_key` | بله | باید با `^[A-Za-z0-9._:-]{8,200}$` بخواند |
| `category` | خیر | `transactional` (پیش‌فرض) یا `critical`. مقدار `marketing` رد می‌شود |
| `vars` | خیر | حداکثر ۴۰ کلید، همه با مقدار رشته‌ای |

```json title="آرگومان‌ها"
{
  "user_id": "u_9137",
  "channel": "sms",
  "template_id": 42,
  "vars": {"code": "8391"},
  "idempotency_key": "order-8821-shipped"
}
```

```json title="پاسخ موفق"
{
  "message_id": "t7.order-8821-shipped",
  "status": "sent",
  "sent_at": "2026-08-07T18:22:31.114Z"
}
```

> [!warn]
> فهرست کانال‌ها در اسکیمای این ابزار یک‌جا غلط است. مقدار `web` را پیشنهاد می‌دهد و این مسیر `web` را نمی‌پذیرد. مقدارهای پذیرفته دقیقا `push` و `sms` و `email` و `webpush` و `inapp` و `bale` و `eitaa` و `rubika` هستند و لفظ به لفظ مقایسه می‌شوند، بدون هیچ ترجمهٔ نام مستعار. مدلی که از روی توضیح `web` بفرستد `400 transactional: unknown channel` می‌گیرد و آن را به‌صورت خشک `http 400` می‌بیند.

> [!warn]
> پاسخ ۲۰۰ یعنی پیام رفت نیست. وقتی `status` برابر `suppressed` باشد و `reason` مقدار داشته باشد، پیام عمدا فرستاده نشده: لغو اشتراک، خاموش‌کردن کانال، فهرست منع، یا نبودن نشانی. توضیح ابزار این را نمی‌گوید، پس عامل موفقیت گزارش می‌کند. پیش از اینکه به کسی بگویید اطلاع‌رسانی رسیده، `status` و `reason` را نگاه کنید.

```json title="پاسخ موفق، و به هیچ‌کس پیامی نرسید"
{
  "message_id": "t7.order-8821-shipped",
  "status": "suppressed",
  "reason": "channel_opt_out",
  "reason_fa": "این کانال را خاموش کرده است",
  "sent_at": "2026-08-07T18:22:31.114Z"
}
```

مقدار `replayed: true` یعنی پاسخ از دفتر یکتایی درآمده و ارسال تازه‌ای نبوده. با همین می‌شود «این کار را قبلا کردیم» را از «همین حالا کردیم» جدا کرد، و وقتی تلاش اول تایم‌اوت خورده و نمی‌دانید کدام اتفاق افتاده، همین مهم است.

همان کلید که هنوز در پرواز باشد ۴۰۹ می‌گیرد با `Retry-After: 1`. پیامی که فرستاده شده ولی سطر دفترش نوشته نشده باز هم ۲۰۰ می‌گیرد، چون ۵۰۳ باعث می‌شود فراخوان دوباره تلاش کند و پیام دومی برود، که از گزارش‌شدن یک سطر گم‌شده بدتر است.

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

## ابزارهای نوشتنی {#write-tools}

همه این ابزارها مسیر API عمومی را صدا می‌زنند. ابزارهای تکرار همراه با مسیرهایشان اضافه شده‌اند و بقیه ابزارها مسیرهای قبلی را در دسترس می‌گذارند.

سه قاعده روی این نیمه برقرار است. **اول**، بدنه با تایپ درخواست خود سرور ساخته می‌شود نه با map دستی، پس اگر کسی نام فیلدی را در بک‌اند عوض کند این فایل کامپایل نمی‌شود. همان کلاس باگی که دو ابزار گزارش را خراب کرده بود، اینجا اصلا در دسترس نیست. **دوم**، اعتبارسنجی دو بار انجام می‌شود، یک بار محلی تا مدل جملهٔ قابل‌اقدام بگیرد و یک بار سمت سرور که تصمیم واقعی همان‌جاست. **سوم**، دو کار برگشت‌ناپذیر اسم خود شیء را پس می‌خواهند.

### segmentic_create_audience و segmentic_update_audience {#write-audiences}

مجوز `segment.write`. ساختن چیزی نمی‌فرستد: مخاطب یک سؤال ذخیره‌شده است و کمپین و سناریو بعدا به آن اشاره می‌کنند.

فیلتر باید تعریف کامل باشد، یعنی `{"version": 1, "root": {...}}`. اگر یک شرط تنها بفرستید، ابزار پیش از تماس با سرور ردش می‌کند و می‌گوید چطور بپیچیدش؛ این شایع‌ترین اشتباه است و پیام خود سرور برایش (`unknown node kind: ""`) به هیچ‌کس نمی‌گوید چه کند.

> [!warn]
> ویرایش، جایگزینی است نه ادغام. کل تعریف با چیزی که می‌فرستید عوض می‌شود، پس اول با `segmentic_get_audience` بخوانیدش. هر کمپین و سناریویی که به این مخاطب اشاره می‌کند، از همان لحظه فیلتر تازه را می‌بیند.

### segmentic_delete_audience {#delete-audience}

مجوز `segment.delete`. اول مخاطب را می‌خواند، بعد `confirm_name` را با نام واقعی‌اش مقایسه می‌کند و اگر یکی نباشد کاری نمی‌کند. سناریو یا کمپینی که به آن اشاره می‌کند با آن حذف نمی‌شود.

### segmentic_create_campaign {#create-campaign}

مجوز `campaign.write`. **همیشه پیش‌نویس می‌سازد**، هرچه هم پاس بدهید. ساختن و فرستادن دو فراخوان‌اند چون یکی‌شان برگشت‌پذیر است.

مخاطب یا `segment_id` است یا `filter`، و اگر هر دو یا هیچ‌کدام را بدهید ابزار ردش می‌کند: دو مخاطب روی یک کمپین چیزی نیست که سرور بتواند حلش کند، و حدس‌زدن اینکه کدام منظور بوده همان‌جایی است که پیام به آدم‌های اشتباه می‌رسد. `template_id` اجباری است، چون متن پیام درجا فرستادنی نیست.

### segmentic_submit_campaign_for_approval {#submit-campaign}

مجوز `campaign.write`. روی حساب‌هایی که تأیید لازم دارند، پیش‌نویس را به صف بازبینی می‌فرستد. چیزی ارسال نمی‌شود. تأیید به وضعیت فعلی کمپین اثر انگشت می‌خورد، پس ویرایش بعد از تأیید یعنی باید دوباره فرستاده شود.

### segmentic_set_campaign_recurrence {#set-campaign-recurrence}

مجوز `campaign.send`. برنامه تکرار یک کمپین را شروع یا جایگزین می‌کند. اول کمپین را می‌خواند و نام دقیق فعلی آن را در `confirm_name` می‌خواهد. یک برنامه می‌تواند در آینده برای همه مخاطبان کمپین بسازد، پس همان محافظ ارسال فوری اینجا هم لازم است.

شیء `recurrence` تناوب `daily`، `weekly` یا `monthly` دارد. `hour` و `hours` با ساعت تهران هستند. شنبه روز `0` هفته است و روز ماه بر پایه تقویم جلالی خوانده می‌شود. ابزار همه این موارد را پیش از تماس با API بررسی می‌کند. خواندن ۵ واحد و نوشتن ۱ واحد هزینه دارد، در مجموع ۶ واحد.

### segmentic_clear_campaign_recurrence {#clear-campaign-recurrence}

مجوز `campaign.send`. تکرارهای خودکار آینده را متوقف می‌کند. کمپین‌هایی را که برنامه قبلا ساخته پاک یا ویرایش نمی‌کند. این تماس ۱ واحد هزینه دارد و فقط `campaign_id` از `segmentic_list_campaigns` می‌خواهد.

### segmentic_send_campaign {#send-campaign}

مجوز `campaign.send`. این ابزار به **همهٔ** مخاطبان کمپین می‌رسد و برگشت ندارد.

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

> [!danger]
> پیش از این فراخوان، تعداد گیرنده را با `segmentic_count_audience` بگیرید و به آدمی که خواسته بگویید. فرق بین چهارصد نفر و چهارصد هزار نفر، یک فیلتر اشتباه است.

### segmentic_ingest_events {#ingest-events}

مجوز `profile.write`. همان دری که بک‌اند خود مشتری از آن رویداد می‌فرستد، پس راهی است برای واردکردن حقیقت بدون کیت مرورگری.

پاسخ ۲۰۰ یعنی درخواست پذیرفته شد، نه اینکه همهٔ رویدادها. آرایهٔ `rejected` هر قلم ردشده را با شمارهٔ خانه‌اش در آرایهٔ شما و دلیلش می‌گوید. برای هر رویداد یک `message_id` پایدار از روی خود واقعیت بگذارید، وگرنه تلاش دوباره همان چیز را دو بار می‌شمارد.

### segmentic_queue_export و segmentic_list_exports {#exports}

مجوز `data.export`. تنها ابزارهایی که خروجی‌شان دادهٔ شخصی دارد، یعنی نشانی ایمیل و شمارهٔ تلفن. فایل از پنل برداشته می‌شود نه از اینجا، پس صف‌کردن یعنی ساختن چیزی که کسی باید برود و بیاوردش. پشت قفل مالی هم هستند.

## چرا نبود یک مجوز، ابزار را پنهان می‌کند و رد نمی‌کند {#registration}

ابزارها از روی کاری که کلید می‌تواند بکند ثبت می‌شوند، نه از روی چیزی که API عرضه می‌کند:

```text
Registered conditionally on what the KEY can do, not on what the API
offers. A tool an agent can see is a tool it will try, and a refusal it
cannot fix reads to the model as a fault worth retrying, so the honest
move is not to offer it.
```

سه چیز از این نتیجه می‌شود و هر سه بالاخره کسی را غافلگیر می‌کنند.

1. فهرست ابزارها موقع بالا آمدن فرایند قفل می‌شود. از روی پاسخ `whoami` ساخته شده که پیش از ساخت سرور گرفته شده بود. باطل‌کردن یک مجوز، یا کل کلید، تا وقتی فرایند را ری‌استارت نکنید فهرست اعلام‌شده را عوض نمی‌کند؛ فقط فراخوانی‌ها سر API شکست می‌خورند.
2. نبودن، نه رد کردن. مجوزی که کلید ندارد یعنی ابزار اصلا در `tools/list` ظاهر نمی‌شود، پس مدل نمی‌بیندش، امتحانش نمی‌کند، و ۴۰۳ را با چیزی که ارزش تلاش دوباره دارد اشتباه نمی‌گیرد.
3. دروازه کلید است، نه آن نصب. سرور هرگز `GET /v1/capabilities` را نمی‌پرسد. کلیدی با `campaign.read` روی نصبی که کمپین سرو نمی‌کند باز هم `segmentic_list_campaigns` را می‌گیرد، و ابزار جواب می‌دهد `unknown_endpoint: no such endpoint: GET /v1/campaigns, see GET /v1/capabilities`.

ابزار `segmentic_whoami` بیرون از همهٔ شرط‌ها نشسته، پس کلیدی که به هیچ‌چیز دسترسی ندارد هم دقیقا یک ابزار عرضه می‌کند.

## چیزی که ابزارها هرگز برنمی‌گردانند {#never-returned}

هیچ ابزاری نشانی ایمیل یا شمارهٔ تلفن برنمی‌گرداند. حتی یکی.

| ابزار | دادهٔ شخصی در پاسخش |
|---|---|
| `segmentic_whoami` | هیچ. نه ایمیل، نه نام، نه شناسهٔ کلید، نه نامک حساب |
| `segmentic_describe_data` | تجمیعی: نام رویداد، حجم، کلید ویژگی‌ها، نام و شمار ویژگی‌ها. هرگز مقدار یک ویژگی |
| `segmentic_ingest_quality` | شمار چیزهایی که ورودی داده رد یا اصلاح کرده، به تفکیک روز و کد و اپ و کتابخانه کلاینت. هرگز یک مقدار، یک شناسه یا متن خطا |
| `segmentic_list_audiences` | نام سگمنت، توضیح، درخت فیلتر و اندازه. یک فیلتر می‌تواند مقداری را که یک بازاریاب تایپ کرده در خود داشته باشد، مثل نام شهر یا نام یک طرح، ولی نام هیچ آدمی نمی‌آید |
| `segmentic_describe_audience` | یک بولین و یک جملهٔ فارسی. هیچ کوئری نمی‌زند |
| `segmentic_count_audience` | یک عدد، یک جمله، یک مدت‌زمان |
| `segmentic_list_campaigns` | فراداده و جمع‌های کمپین |
| `segmentic_campaign_report` | فقط تجمیعی: هر سطر یک شمارش است که بر اساس یک دلیل یا یک کانال گروه شده |
| `segmentic_funnel_report` | شمار هر مرحله |
| `segmentic_retention_report` | یک شبکه از شمارش‌ها |
| `segmentic_send_transactional_message` | همان `user_id` که خودتان داده‌اید را پس می‌دهد. نه نشانی، نه متن رندرشده |

تضمین قوی‌تر یک فیلتر نیست، نبودن است. نه ابزاری برای پرونده هست، نه ابزار خط زمانی، نه پیش‌نمایش سگمنت، نه خروجی، و نه جستجو. تنها هندلر داخلی که نام و نشانی ایمیل و شمارهٔ تلفن و شهر آدم‌های واقعی را برمی‌گرداند روی شنوندهٔ خود داشبورد ثبت شده و از این API اصلا در دسترس نیست. سرور MCP هیچ ابزار شمارش‌کننده‌ای ندارد، چون مسیرهایی که چنین چیزی می‌دادند اصلا صدا زده نمی‌شوند.

> [!warn]
> یک چیز تضمین نشده است. متنی که خود تیم شما نوشته بدون حصار به مدل می‌رسد: نام سگمنت، نام کمپین و توضیح‌های فارسی به‌صورت رشتهٔ JSON ساده می‌آیند و هیچ پوشش «محتوای نامعتمد» ندارند. اگر کسی نام یک سگمنت را دستوری بگذارد که به مدل نشانه رفته، مدل آن را متن معمولی می‌خواند. فیلدهای متن آزاد پنل را یک کانال ورودی به عامل خودتان حساب کنید.

## عامل چه چیزی را غلط می‌کند، و توضیح‌ها چه می‌کنند {#steering}

فایل سه حالت شکست را نام می‌برد. دو تا از پادزهرها واقعی‌اند و یکی نیست، و دانستن اینکه کدام کدام است می‌ارزد.

**شناسه از خودش درمی‌آورد.** پادزهرش فقط جمله است. ابزار `segmentic_describe_data` می‌گوید همیشه اول این را صدا بزن و نتیجه‌اش را توضیح می‌دهد. ابزار `segmentic_list_audiences` می‌گوید شناسه را پیدا کن و حدس نزن. ابزار `segmentic_funnel_report` می‌گوید نام گام‌ها باید از `segmentic_describe_data` بیاید. توضیح آرگومان‌های `campaign_id` و `filter` همین را تکرار می‌کنند. هیچ‌چیز هیچ‌کدام را اجبار نمی‌کند: فیلتری که نام رویداد ناموجود را ببرد تمیز کامپایل می‌شود و صفر برمی‌گرداند، که دقیقا شبیه یک مخاطب واقعی صفرنفره است. نه حالت نشست هست، نه پیش‌شرط، و نه پیشنهاد «منظورت این بود».

**بیشتر از نیازش می‌خواند.** پادزهر اعلام‌شده صفحه‌بندی سمت سرور با سقفی است که مدل نمی‌تواند بالا ببرد. نصفش در کد هست و نصفش نیست: سقف واقعا وجود دارد و مدل هم نمی‌تواند بالا ببردش، چون یک `LIMIT 200` ثابت در SQL است، ولی صفحه‌بندی نیست و هیچ راهی به سطر دویست‌ویکم وجود ندارد. کاری که شد این بود که ابزارها دیگر وانمود نکنند دارند: آرگومان‌های بی‌اثر برداشته شدند و توضیح هر دو ابزار خود سقف را به مدل می‌گوید. پادزهر واقعی نیمهٔ دوم همان جمله است، اینکه هیچ ابزاری دادهٔ تماس برنمی‌گرداند، و واقعی است چون آن ابزارها اصلا نوشته نشدند.

**در حلقه می‌افتد.** این یکی برقرار است. بودجه وزن‌دار است و سمت سرور اعمال می‌شود، برای هر کلید و در هر دقیقه، پس مدلی که یک گزارش سنگین را دوباره و دوباره می‌زند را API رد می‌کند و نه ادب کلاینت. بودجه عمدا برای هر کلید جداست: مشتری یک کلید باریک به عامل می‌دهد و کلید یکپارچه‌سازی خودش را جدا نگه می‌دارد، و عامل از کنترل خارج‌شده نباید بتواند بودجه‌ای را که خط سفارش‌ها به آن وابسته است ته بکشد. توضیح‌ها هم به همان سمت هل می‌دهند: به مدل می‌گویند یک بار بشمار و نه اینکه واریاسیون‌ها را در حلقه بشمارد، برای هر سؤال یک فراخوانی خرج کن و نه برای هر فرضیه، و کل شبکهٔ ماندگاری را یک‌جا بخوان و نه کوهورت به کوهورت. ابزار `segmentic_describe_audience` عمدا رایگان تبلیغ شده تا مدل پیش از خرج‌کردن تمرین کند.

متن خطا هم به همین دلیل بدون بازنویسی رد می‌شود:

```text
The API's own code and message, passed through rather than
paraphrased. "budget_exhausted" tells a model to wait; a rewritten
"something went wrong" tells it to retry immediately, which is the
opposite of what the server just asked for.
```

## بودجه {#budget}

هر فراخوانی سرور از همان بودجهٔ درخواست هر کلید خرج می‌کند که هر کلاینت دیگر API خرج می‌کند: پیش‌فرض ۶۰۰ واحد برای هر کلید در هر دقیقه، در یک پنجرهٔ ثابت. بالا آمدن خودش ۱ واحد بابت `whoami` خرج می‌کند، پیش از اجرای هر ابزاری.

ته‌کشیدن بودجه ۴۲۹ است با `Retry-After: 60` و کد `budget_exhausted` که سالم به مدل می‌رسد. اگر خود شمارندهٔ بودجه در دسترس نباشد، درخواست رد می‌شود و اجازه داده نمی‌شود: این یکی بسته شکست می‌خورد.

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

سقف روزانهٔ گیرنده و سقف روزانهٔ سطرهای دادهٔ شخصی برای هر کلید وجود ندارند. ستون‌هایشان در یک مهاجرت هستند و هیچ‌چیز آن‌ها را نمی‌خواند و نمی‌نویسد.

## مدل هنگام شکست چه می‌بیند {#errors}

شکست‌ها به‌صورت محتوای ابزار با `isError: true` برمی‌گردند، پس مدل متن را می‌خواند و می‌تواند کاری بکند. اینکه متن چه بگوید بستگی دارد کدام بخش سرور خطا را نوشته باشد، و این ناهموارترین لبهٔ کل این برنامه است.

کلاینت فقط یک شکل خطا را می‌فهمد، همان که لبهٔ عمومی می‌نویسد: `{"error": {"code": ..., "message": ...}}`. چند هندلر پشت آن لبه شکل دیگری می‌نویسند که در آن `error` یک رشتهٔ ساده است. کلاینت آن‌ها را نمی‌تواند بخواند، پس فقط عدد وضعیت را گزارش می‌کند و بس.

سالم می‌رسند، با کد و پیام:

| کد | وضعیت |
|---|---|
| `unauthenticated` | ۴۰۱ |
| `write_key_rejected` | ۴۰۱ |
| `key_expired` | ۴۰۱ |
| `forbidden`، با نام مجوز در `need` | ۴۰۳ |
| `account_locked` | ۴۰۳ |
| `filter_invalid` | ۴۲۲ |
| `budget_exhausted` | ۴۲۹ |
| `budget_unavailable` | ۵۰۳ |
| `unknown_endpoint` | ۴۰۴ |

به یک عدد خشک تنزل می‌کنند:

| چه چیزی واقعا شکست خورد | به مدل چه گفته می‌شود |
|---|---|
| فیلتری که در شمارش کامپایل نشد | `http 400` |
| همهٔ خطاهای اعتبارسنجی قیف و ماندگاری | `http 400` |
| همهٔ خطاهای فراخوان در ارسال، از جمله کانال غلط | `http 400` |
| محدودکنندهٔ نرخ ارسال | `http 429` |
| شناسهٔ کمپینی که وجود ندارد | `http 404` |
| در دسترس نبودن اسکیما، فهرست سگمنت، فهرست کمپین یا کوئری شمارش | `http 503` |

پس خطایی که طراحی بیشتر از همه می‌خواهد مدل درست بخواندش، یعنی `budget_exhausted`، کامل می‌رسد. خطاهایی که مدل بیشتر از همه برای درست‌کردن ورودی خودش لازم دارد، نمی‌رسند. وقتی عاملی `http 400` گزارش کرد و نتوانست بگوید چرا، همان فراخوانی را با curl بزنید و بدنه را بخوانید.

بدنهٔ پاسخ تا ۴ مبی‌بایت خوانده می‌شود. بزرگ‌تر از آن بریده می‌شود، بعد پارس نمی‌شود، و ابزار `unreadable response` را با ۲۰۰ نویسهٔ اول گزارش می‌کند.

## چه کاری را عمدا نمی‌کند، و چه چیزی اصلا نیست {#absent}

عمدی:

- هیچ ابزاری که یک آدم برگرداند. نه جستجوی پرونده، نه خط زمانی، نه پیش‌نمایش سگمنت، نه جستجو. تنها راهی که دادهٔ شخصی از اینجا بیرون می‌آید، خروجی است که خودش پشت `data.export` و قفل مالی است.
- هیچ فلگی برای عوض‌کردن اینکه کلید چه می‌تواند بکند. نه `--allow-write` و نه `--allow-send`.
- هیچ دسترسی مستقیم به دیتابیس. همهٔ دروازه‌ها سر لبهٔ HTTP می‌مانند، جایی که از قبل تست شده‌اند.
- هیچ کار برگشت‌ناپذیری بدون تأیید اسم. ارسال کمپین و حذف مخاطب هر دو شیء را اول می‌خوانند و اگر اسم را نتوانند بخوانند، انجام نمی‌دهند.

نیست، و دست‌کم یکی از این‌ها را لازم خواهید داشت:

- **ابزار سناریو و قالب.** این دو هنوز روی API عمومی مسیری ندارند، پس MCP هم نمی‌تواند سناریو یا قالب بسازد و ویرایش کند. جالب اینکه `GET /v1/capabilities` همین حالا `journeys` را گزارش می‌کند، که دربارهٔ خود نصب راست است و دربارهٔ این سطح API نه. کارت خودش را دارد.
- هر بیلد منتشرشده‌ای. نه ریلیز، نه ایمیج مستقل، نه فایل اجرایی مک یا ویندوز. خودتان کامپایل می‌کنید.
- فرم انتخاب مجوز در پنل. خود کلید محدودشده حالا ساخته می‌شود، ولی از API؛ لیست کشویی‌اش هنوز در صفحهٔ کلیدها نیست.
- صفحه‌بندی روی دو مسیر فهرست. هر کدام روی ۲۰۰ سطر تازه‌تر بریده می‌شوند و هیچ‌چیز در پاسخ نمی‌گوید بریده شده‌اند. ابزارها دست‌کم دیگر آرگومان صفحه‌بندی تعارف نمی‌کنند و سقف را در توضیحشان می‌گویند، ولی سطر دویست‌ویکم همچنان دست‌نیافتنی است.
- خطاهای اعتبارسنجی خواندنی، طبق جدول بالا.
- لاگ به‌ازای هر ابزار. برنامه یک خط موقع بالا آمدن می‌نویسد و بعد از آن هیچ. لاگ حسابرسی حساب سازوکار جداگانه‌ای است و خواندن‌ها را ثبت نمی‌کند، پس هیچ ردی از اینکه یک عامل چه چیزی را نگاه کرده وجود ندارد.
- دستورالعمل سطح سرور در MCP. همهٔ هدایت داخل توضیح تک‌تک ابزارها زندگی می‌کند، که کلاینت ممکن است کامل به مدل نشان بدهد یا ندهد.
- پوشش تست فراتر از شکل درخواست. تست‌های امروز بدنه‌ای که هر ابزار می‌سازد را به تایپ درخواست خود سرور می‌دهند و همان تابع کامپایلی را رویش می‌دوانند که هندلر HTTP صدا می‌زند، که همان چیزی است که دو ابزار خراب را گرفت. رفتار سرتاسری در برابر یک API واقعی هنوز آزموده نمی‌شود.

اگر امروز به چیزی از فهرست دوم نیاز دارید، مستقیم از [API مدیریتی](/docs/api/management) استفاده کنید. هر کاری که سرور MCP می‌کند یک درخواست HTTP است، و هیچ‌چیز در آن نیست که با curl نشود انجام داد.

---

# دادن مستندات به کلود یا کدکس

> نسخهٔ یک‌فایلی مستندات، و پرامپت آماده‌ای که با آن می‌گویید «این سرویس را برایم وصل کن».
> https://segmentic.net/docs/ai

بیشتر کسانی که این روزها سگمنتیک را وصل می‌کنند، یک عامل هوش مصنوعی کنارشان نشسته است. این صفحه می‌گوید چه چیزی به آن عامل بدهید، و اگر ندهید چه چیزهایی را غلط انجام می‌دهد.

اگر می‌خواهید عامل به جای نوشتن کد، خود حساب شما را کوئری بزند، آن کار دیگری است و در [صفحهٔ سرور MCP](/docs/mcp) توضیح داده شده.

## سه فایل، و اینکه کدام را بدهید {#files}

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

| فایل | چیست | کی به کار می‌آید |
|---|---|---|
| [/llms.txt](/llms.txt) | فهرست. برای هر صفحه یک خط، با نشانی و یک جمله توضیح، به‌علاوهٔ یک شرح کوتاه از اینکه سگمنتیک چیست. چند کیلوبایت. | وقتی عامل می‌تواند نشانی‌ها را بگیرد. فهرست را می‌خواند و بعد دو سه صفحهٔ لازم را می‌گیرد. |
| [/llms-full.txt](/llms-full.txt) | همهٔ صفحه‌های انگلیسی پشت سر هم، با سرصفحه‌ای که میزبان‌ها و نوع کلیدها را نام می‌برد. | وقتی عامل نمی‌تواند نشانی بگیرد، یا وقتی کار «این سرویس را وصل کن» است و همه‌چیز را یک‌جا لازم دارد. |
| [/docs-en.md](/docs-en.md) | همان متن `llms-full.txt`، ولی به‌صورت فایلی با نام و با هدر دانلود. | وقتی فایلی می‌خواهید که به پیامی پیوست شود، در مخزن بماند، یا آفلاین خوانده شود. |
| [/docs-fa.md](/docs-fa.md) | همهٔ صفحه‌های فارسی در یک فایل دانلودی به نام `segmentic-docs-fa.md`. | همان کار، به فارسی، که زبان اصلی این صفحه‌هاست. |
| [/openapi.json](/openapi.json) | هر دو سطح HTTP، ماشین‌خوان. هم JSON معتبر است و هم YAML معتبر. | برای ساختن کلاینت، یا دادن به ابزاری که به جای نثر، اسکیما می‌خواهد. |

```bash title="گرفتنشان"
curl -s https://segmentic.net/llms.txt
curl -s -o segmentic-docs.md https://segmentic.net/docs-en.md
curl -s -o segmentic-docs-fa.md https://segmentic.net/docs-fa.md
curl -s -o segmentic-openapi.json https://segmentic.net/openapi.json
```

فایل `llms.txt` انگلیسی است، هرچند نسخهٔ اصلی صفحه‌ها فارسی است. خوانندهٔ آن فایل یک مدل است که دارد انتخاب می‌کند کدام صفحه را بگیرد، نمونه‌های خود آن قرارداد انگلیسی‌اند، و هر مدلی که امروز استفاده می‌شود فهرست انگلیسی را مطمئن‌تر از فارسی می‌خواند. صفحه‌هایی که به آن‌ها اشاره می‌کند به هر دو زبان هستند و مجموعهٔ فارسی در پای همان فایل لینک شده.

فایلی به نام `llms-full-fa.txt` وجود ندارد. آن قرارداد یک نام دارد، و نام دومی که ما از خودمان بسازیم را هیچ‌چیز پیدا نمی‌کند. بستهٔ فارسی همان `/docs-fa.md` است.

فایل `llms-full.txt` پیش از اولین صفحه یک سرصفحه دارد که دو میزبان و دو نوع کلید را با لحن امری می‌گوید. آن سرصفحه تزیین نیست. عاملی که صفحه‌ها را بدون آن بگیرد، به سؤال‌های مربوط به میزبان و کلید از روی چیزی جواب می‌دهد که از محصول‌های تحلیلی دیگر به یاد دارد، و دو تا از گران‌ترین اشتباه‌هایش همین است: میزبان غلط و نوع کلید غلط.

## پرامپتی که می‌شود کپی کرد {#prompt}

همین‌طور که هست کپی کنید. برای اینکه اولین پیام یک نشست باشد نوشته شده، و هر بندش به این دلیل آنجاست که بدون آن، یک عامل آن را غلط انجام داده بود.

```text title="این را به کلود کد یا کدکس بدهید"
محصول من را به سگمنتیک وصل کن، که یک پلتفرم جمع‌آوری رویداد و ارسال پیام است.

اول https://segmentic.net/llms-full.txt را کامل بخوان. کل مستندات همان است.
از روی حافظه‌ات دربارهٔ محصول‌های تحلیلی دیگر جواب نده: میزبان‌ها، نام کلیدها،
کدهای خطا و مدل رویداد اینجا فرق دارند، و یک حدس باورپذیر یک بعدازظهر از من
می‌گیرد.

دو میزبان هست و دو نوع کلید. جای همدیگر را نمی‌گیرند.

  فرستادن رویداد، از سایت یا اپ موبایل من:
    میزبان   https://in.segmentic.net
    کلید     wk_seg_...   ذاتا عمومی است، داخل باندل سمت کاربر می‌رود،
                          و فقط می‌تواند رویداد بنویسد

  خواندن و مدیریت مخاطب و کمپین و گزارش، از بک‌اند من:
    میزبان   https://api.segmentic.net
    کلید     sk_seg_...   محرمانه، فقط سمت سرور، و مجوز حمل می‌کند

  پنلی که آدم با آن کار می‌کند:  https://app.segmentic.net

قاعده‌هایی که می‌خواهم بدون یادآوری دوباره رعایتشان کنی:

1. کلید sk_seg_ را هیچ‌جایی که به یک دستگاه می‌رسد نگذار. نه در کد مرورگر،
   نه در اپ موبایل، نه در متغیر محیطی با نام NEXT_PUBLIC_ یا VITE_، و نه در
   فایل پیکربندی سمت کاربر. اگر قابلیتی به آن نیاز دارد، آن فراخوانی روی
   سرور من انجام می‌شود.

2. پیش از انتخاب هر نام رویداد، صفحهٔ تعریف رویداد و فرهنگ‌نامهٔ رویدادها را
   بخوان. سرور هیچ قاعدهٔ نام‌گذاری‌ای را اجبار نمی‌کند و تقریبا هر رشته‌ای را
   می‌پذیرد. پس نام بد یک خطا نیست، تاریخچه است.

3. نام رویداد یا ویژگی موجود را هرگز حدس نزن. با کلید sk_seg_ از خود سرور
   بپرس:
     GET https://api.segmentic.net/v1/schema/events
     GET https://api.segmentic.net/v1/schema/traits
   نامی که وجود ندارد همه‌جا پذیرفته می‌شود و هیچ‌کس را نمی‌گیرد، که دقیقا
   شبیه یک مخاطب واقعی صفرنفره است. اگر به این مسیرها دسترسی نداشتی، به جای
   حدس‌زدن بایست و از من بپرس.

4. پیش از اینکه فرض کنی یک فراخوانی مدیریتی کار می‌کند،
   GET https://api.segmentic.net/v1/whoami را بزن. فهرست دقیق مجوزهای آن
   کلید را برمی‌گرداند. GET /v1/capabilities را هم بزن تا ببینی این نصب چه
   قابلیت‌هایی را سرو می‌کند و چه سقف‌هایی را اعلام می‌کند، و همان عددها را
   استفاده کن نه عددی که خودت در کد می‌نویسی.

5. پیش از نوشتن هر منطق تلاش دوباره، صفحهٔ کدهای خطا را بخوان. کد 401 یعنی
   اعتبارنامه غلط است و تلاش دوباره هیچ‌وقت درستش نمی‌کند. کد 429 با
   budget_exhausted یعنی صبر کن، و Retry-After می‌گوید چقدر. کد 503 گذراست و
   ارزش تلاش دوباره با فاصله دارد.

6. هر چیزی که به یک آدم واقعی پیام می‌فرستد: پیش از فرستادن، دقیقا همان
   فراخوانی را به من نشان بده و تا وقتی نگفتم بله، نفرست.

کارت را با پیشنهاد فهرست رویدادهایی شروع کن که محصول من باید بفرستد، و برای
هرکدام یک خط بنویس که به چه سؤالی جواب می‌دهد. تا وقتی سر آن فهرست به توافق
نرسیده‌ایم، هیچ کدی ننویس.
```

بسته به کار، دو چیز را شاید بخواهید اضافه کنید. اگر عامل دارد یکپارچه‌سازی سمت سرور می‌نویسد، زبان و فریم‌ورک را بگویید، چون مستندات با curl نوشته شده و در غیر این صورت خودش برایتان انتخاب می‌کند. اگر از قبل رویداد می‌فرستید و حالا پیام‌رسانی اضافه می‌کنید، بگویید از `GET /v1/schema/events` شروع کند و روی نام‌هایی کار کند که همین حالا هستند.

## عامل‌ها اینجا دقیقا چه چیزی را غلط می‌کنند {#pitfalls}

این‌ها هشدار کلی دربارهٔ مدل‌های زبانی نیستند. هرکدام یک شکست است که همین API تولید می‌کند، و بیشترشان در توضیح‌های سرور MCP نام برده شده‌اند، که بعد از تماشای کار عامل‌ها نوشته شده است.

**شناسه از خودش درمی‌آورد و هیچ‌کس تذکر نمی‌دهد.** فیلتری که نام رویداد ناموجود را ببرد تمیز کامپایل می‌شود و صفر برمی‌گرداند. برای یک مدل این یعنی «هیچ‌کس مطابقت ندارد» و نه «چنین رویدادی وجود ندارد»، پس عدد را گزارش می‌کند و رد می‌شود. گران‌ترین اشتباه اینجا همین است، و دلیل اینکه مسیرهای اسکیما در پرامپت بالا آمده‌اند.

**خیلی بیشتر از نیازش می‌خواند، و بعد فکر می‌کند همه‌اش را دیده.** مسیرهای `GET /v1/segments` و `GET /v1/campaigns` آرگومان‌های `limit` و `cursor` را می‌پذیرند و هر دو را نادیده می‌گیرند. هر کدام ۲۰۰ سطری را می‌دهند که تازه‌تر از همه به‌روز شده‌اند، از جمله کل درخت فیلتر هر سگمنت، پس عاملی که بپرسد «چه مخاطب‌هایی داریم» همان دویست‌تا را داخل کانتکست خودش می‌کشد. سقف بی‌صداست: نه `next_cursor` می‌آید، نه `has_more`، نه شماری. روی حسابی با ۲۵۰ سگمنت، عامل با اطمینان کامل فهرستی ناقص را کامل گزارش می‌کند و راهی هم نیست که به آن ۵۰ تای دیگر برسد.

**روی فراخوانی گران در حلقه می‌افتد.** شمردن یک مخاطب ۲۵ واحد از بودجهٔ ۶۰۰ واحدی هر دقیقه می‌برد، پس ۲۴ شمارش در دقیقه آن را ته می‌کشد. عاملی که واریاسیون‌های فیلتر را امتحان کند، در کمتر از یک دقیقه به `budget_exhausted` می‌خورد. به عامل کلید خودش را بدهید: بودجه برای هر کلید جدا شمرده می‌شود، پس یک عامل از کنترل خارج‌شده نمی‌تواند کلیدی را که خط سفارش‌های شما به آن وابسته است گرسنه بگذارد.

**نرخ‌ها را در یک درصد جمع می‌کند.** پیامک رسید خواندن ندارد، پس شمار بازشدن یک کمپین پیامکی صفر است و هیچ معنایی ندارد. هر نرخی در گزارش کمپین با صورت کسر و یک مخرج نام‌دار می‌آید، یعنی `measurable_open` و `measurable_click` کنار `opened` و `clicked`. عاملی که `opened` را بر `issued` تقسیم کند، برای پیامک نرخ بازشدن صفر درصد گزارش می‌کند که با اطمینان کامل غلط است. به او بگویید هر دو عدد را نقل کند.

**وسط یک بازه را مثل یک اندازه‌گیری نقل می‌کند.** بخش اثر افزوده `lift` و `lift_low` و `lift_high` را دارد. تخمین نقطه‌ای به‌تنهایی یک اندازه‌گیری نیست، وسط یک بازه است، و عاملی که فقط بگوید کمپین تبدیل را چند درصد بالا برد، تنها عددی را دور ریخته که می‌گوید اصلا کمپین کار کرده یا نه.

**کلید یکتایی از خودش می‌سازد.** در ارسال تراکنشی، کلید باید همان رویداد دنیای واقعی را شناسایی کند، مثلا `order-8821-shipped`. عاملی که کلید تصادفی بسازد، در هر تلاش دوباره یک کلید تازه دارد، و آن تلاش دوباره پیام دومی به گوشی یک آدم واقعی می‌فرستد.

**پاسخ ۲۰۰ را «تحویل شد» می‌خواند.** ارسال تراکنشی می‌تواند ۲۰۰ برگرداند با `status` برابر `suppressed` و یک `reason`، که یعنی پیام عمدا فرستاده نشده: لغو اشتراک، خاموش‌بودن کانال، فهرست منع، یا نبودن نشانی. پیش از اینکه به کسی بگویید اطلاع‌رسانی رفت، `status` را نگاه کنید.

**بیشتر خطاهای اعتبارسنجی را نمی‌تواند بخواند.** API عمومی برای خطاهای احراز هویت، مجوز، بودجه و فیلتر یک کد و یک پیام می‌دهد. چند هندلر پشت آن، شکل ساده‌تری می‌دهند که کلاینت‌ها نمی‌توانند پارسش کنند، پس عامل `http 400` گزارش می‌کند و نمی‌تواند بگوید چرا. هر وقت این شد، خودتان همان فراخوانی را با curl بزنید و بدنه را بخوانید. جدول کاملش در [صفحهٔ کدهای خطا](/docs/errors) است.

**به متنی که خود تیم شما نوشته اعتماد می‌کند.** نام سگمنت، نام کمپین و توضیح‌های فارسی به‌صورت متن ساده و بدون هیچ پوشش «محتوای نامعتمد» به مدل می‌رسند. اگر کسی نام یک سگمنت را دستوری بگذارد که به مدل نشانه رفته، مدل آن را دستور می‌خواند. فیلدهای متن آزاد پنل را یک کانال ورودی به عامل خودتان حساب کنید، چون همان هستند.

## بررسی چیزی که عامل ساخته {#checking}

چهار دستور، به همان ترتیبی که ارزش دارد اجرا شوند.

```bash title="این کلید واقعا چه چیزی حمل می‌کند"
curl -s https://api.segmentic.net/v1/whoami \
  -H "Authorization: Bearer sk_seg_REPLACE_ME"
```

```json title="پاسخ"
{
  "tenant_id": 7,
  "api_key_id": 3,
  "role": "analyst",
  "permissions": [
    "analytics.read", "audit.read", "campaign.read", "data.export",
    "event.read", "journey.read", "member.read", "profile.read",
    "segment.read", "settings.read", "template.read"
  ],
  "scoped": false
}
```

```bash title="این نصب چه چیزی را سرو می‌کند و سقف‌هایش چیست"
curl -s https://api.segmentic.net/v1/capabilities \
  -H "Authorization: Bearer sk_seg_REPLACE_ME"
```

```json title="پاسخ"
{
  "version": "v1",
  "features": {
    "segments": true,
    "campaigns": true,
    "analytics": true,
    "transactional": true,
    "export": false,
    "import": true,
    "journeys": true,
    "ingest": true,
    "async_exports": true,
    "campaign_approval": true
  },
  "limits": {
    "max_page_size": 100,
    "max_preview_rows": 100,
    "max_batch_size": 500,
    "estimate_sample": 100,
    "query_timeout_sec": 30
  }
}
```

مقدارهای `features` بالا را نمونه‌ای برای کپی‌کردن حساب نکنید. هر پرچم دقیقا یعنی «آیا این زیرسیستم روی این نصب سیم‌کشی شده»، پس از نصبی تا نصب دیگر فرق می‌کند و کل نکته‌اش خواندن آن است نه دانستنش. سه تای آن‌ها هم، `export` و `import` و `journeys`، روی این mux هیچ مسیری را باز نمی‌کنند: `true` بودنشان به شما نقطهٔ پایانی عمومی نمی‌دهد.

این سقف‌ها به جای مستندشدن، اعلام می‌شوند، تا هیچ کلاینت و هیچ عاملی عددی را در کد نبندد که ما بعدا عوضش می‌کنیم. اگر عامل به جای خواندن `max_batch_size` عدد `500` را داخل حلقهٔ دسته‌بندی نوشته، همان یک نکتهٔ بازبینی کد است.

```bash title="آیا رویداد واقعا رسید، با همان نامی که انتظار دارید"
curl -s https://api.segmentic.net/v1/schema/events \
  -H "Authorization: Bearer sk_seg_REPLACE_ME"
```

در هر سطر `last_seen` را بخوانید، نه فقط `volume` را. رویدادی با حجم بزرگ و تاریخ آخرین دیده‌شدنی مال سه هفته پیش، یعنی یکپارچه‌سازی‌ای که خراب شده، و حجم به‌تنهایی تا یک ماه بعدش هم سالم به نظر می‌رسد، چون پنجره نود روزه است.

بعد چیزی را که به مرورگر می‌رود بگردید. این همان بررسی‌ای است که کسی انجامش نمی‌دهد و بیشتر از همه اهمیت دارد:

```bash title="کلید API هرگز نباید به یک دستگاه برسد"
grep -r "sk_seg_" ./dist ./build ./.next ./public 2>/dev/null
grep -rn "NEXT_PUBLIC_.*SEG\|VITE_.*SEG" ./src 2>/dev/null
```

هرچه دستور اول پیدا کند یک راز است که حالا باید باطلش کنید، از مسیر **تنظیمات، اتصال‌ها و یکپارچه‌سازی، کلیدهای API** در پنل. باطل‌کردن از همان درخواست بعدی اثر می‌گذارد.

---

# وب‌هوک ورودی و اتصال به سرویس‌های دیگر

> گرفتن رویداد از سرویسی که SDK ندارد، و اتصال‌هایی که آماده‌اند.
> https://segmentic.net/docs/webhooks

## وب‌هوک ورودی چیست {#what-this-is}

راهی برای گرفتن رویداد از سرویسی که SDK ما را ندارد و نمی‌خواهید داشته باشد.

فروشنده‌ای که فقط روی دیجی‌کالا می‌فروشد سایتی ندارد که SDK رویش بنشیند. فروشگاهی که ووکامرس دارد، دارد؛ ولی راه‌اندازی یک افزونه و انتشار دوباره‌ی سایت، هفته‌ها بعد از تصمیم اتفاق می‌افتد. وب‌هوک این فاصله را حذف می‌کند: یک نشانی را در پنل آن سرویس paste می‌کنید و سفارش‌ها شروع به آمدن می‌کنند. SDK بعدا ارتقا است و نه پیش‌نیاز.

اینجا هیچ کلید نوشتنی در کار نیست. حساب شما از خود نشانی به‌علاوه‌ی امضا مشخص می‌شود، چون در فرم مدیریت آن پلتفرم جایی برای گذاشتن یک هدر وجود ندارد.

## آدرس و توکن {#endpoint}

```text
POST https://in.segmentic.net/v1/hooks/{source}/{token}
```

`{source}` یکی از هفت منبعی است که در [منبع‌ها](/docs/webhooks#sources) آمده و `{token}` رشته‌ای است که هنگام ساختن اتصال، سرور آن را می‌سازد.

سقف بدنه یک مگابایت است، دقیقا `1048576` بایت. سفارش شاپیفای با دویست قلم کالا حدود دویست کیلوبایت است، پس این سقف سخاوتمندانه است و در عین حال آن‌قدر کوچک هست که یک POST بدخواه نتواند ما را وادار به بافر کردن یک گیگابایت کند.

بایت‌های خام نگه داشته می‌شوند و امضا **قبل از هر تجزیه‌ای** روی همان‌ها بررسی می‌شود. رایج‌ترین باگ وب‌هوک در دنیا همین است: decode و encode دوباره، ترتیب کلیدها و فاصله‌ها و قالب اعداد را عوض می‌کند و امضا روی محتوایی که کاملا اصیل بوده شکست می‌خورد.

این مسیر فقط وقتی ثبت می‌شود که نصب شما اتصال‌ها را فعال داشته باشد.

### ساختن اتصال در پنل {#creating-a-connection}

> نمودار: چرخه تاییدشده داده میان سامانه فروش، پرونده‌های سگمنتیک، اتوماسیون و وب‌هوک خروجی

سه مسیر مدیریتی، هر سه روی کنترل‌پلین، یعنی همان APIای که پنل با آن حرف می‌زند. روی میزبان مدیریتی هیچ مسیری برای مدیریت اتصال وجود ندارد.

> [!danger]
> **کنترل‌پلین از اینترنت مسیردهی نشده است.** در استقرار مرجع، `api.segmentic.net` فقط API مدیریتی را روی شنونده‌ی خودش سرو می‌کند و شنونده‌ی کنترل‌پلین عمدا منتشر نشده است. همین سه مسیر روی `https://api.segmentic.net` به هندلر پیش‌فرض می‌افتند و `404 unknown_endpoint` می‌گیرند. تنها مسیر عمومی به آن‌ها، پروکسی سمت سرور خود پنل است روی `https://app.segmentic.net/api/proxy/v1/...` که با کوکی نشست کاربر واردشده احراز هویت می‌کند و بدون آن `401` می‌دهد. کلید `sk_seg_` به آن نمی‌رسد، پس ساختن اتصال کاری است که یک آدم در صفحه‌ی یکپارچه‌سازی‌های پنل انجام می‌دهد.

| متد و مسیر | دسترسی لازم |
| --- | --- |
| `GET /v1/integrations` | `settings.read` |
| `PUT /v1/integrations` | `settings.write` |
| `POST /v1/integrations/{source}/enabled` | `settings.write` |

```http title="ساختن یا به‌روزرسانی یک اتصال"
PUT /api/proxy/v1/integrations
Content-Type: application/json

{"source":"woocommerce","label":"فروشگاه اصلی","secret":"a-shared-secret"}
```

```json title="پاسخ"
{
  "id": 3,
  "source": "woocommerce",
  "label": "فروشگاه اصلی",
  "token": "yVJk3rQx7Pd1wNfZ0aLbCsTu",
  "has_secret": true,
  "enabled": true,
  "received": 0,
  "accepted": 0,
  "rejected": 0,
  "source_label": "ووکامرس",
  "webhook_url": "https://in.segmentic.net/v1/hooks/woocommerce/yVJk3rQx7Pd1wNfZ0aLbCsTu",
  "healthy": false
}
```

نکته‌های این پاسخ:

- `secret` فقط نوشتنی است و هرگز برنمی‌گردد. فقط `has_secret` می‌گوید که مقداری ذخیره شده است. این یک تصمیم است نه یک کمبود: رمز با کلید مهروموم‌کردن نصب رمز می‌شود و هیچ مسیری آن را پس نمی‌دهد، پس کلید مدیریتی‌ای که لو برود رمزهای وب‌هوک شما را با خودش نمی‌برد. اگر در ذخیره‌ی بعدی `secret` را خالی بگذارید، مقدار ذخیره‌شده دست‌نخورده می‌ماند، پس تغییر دادن `label` نیازی به دانستن دوباره‌ی رمز ندارد.
- `token` را سرور در نخستین ذخیره می‌سازد: بیست‌وچهار بایت تصادفی، base64url بدون padding. **ذخیره‌های بعدی آن را نمی‌چرخانند**، چون این توکن داخل پنل مدیریت یک پلتفرم دیگر paste شده و عوض کردنش یعنی شکستن بی‌صدای اتصالی که کار می‌کرد، آن هم درست وقتی کسی فقط نام اتصال را عوض کرده است.
- `webhook_url` را سرور سرهم می‌کند تا کسی مجبور نباشد آن را از یک توکن و یک پایه بسازد و اشتباه کند. **اگر متغیر محیطی `EMAIL_TRACK_BASE` روی سرویس API تنظیم نشده باشد، این فیلد رشته‌ی خالی برمی‌گردد و نه اینکه اصلا نیاید، و پنل نشانی‌ای برای copy کردن ندارد.** کلاینتی که نبودن کلید را بررسی کند مسیر اشتباه را می‌رود؛ مقدارش را بررسی کنید.
- `healthy` برابر `enabled && last_error == "" && accepted > 0` است. یعنی اتصال تازه‌ساخته‌ای که هنوز چیزی نگرفته، ناسالم خوانده می‌شود. این عمدی است و نه باگ.
- `source` باید یکی از هفت مقدار شناخته‌شده باشد، وگرنه پاسخ `400` است.

`last_error` فقط برای شکست نگه داشته می‌شود، در نخستین موفقیت بعدی پاک می‌شود، و به ۵۰۰ بایت بریده می‌شود. این فیلد `omitempty` است، پس اتصالی که هیچ شکستی در پرونده‌اش نیست اصلا کلید `last_error` ندارد، و به همین دلیل در پاسخ بالا نیامده. شمارنده‌های `received`، `accepted` و `rejected` بعد از انتشار رویدادها و به‌صورت fire-and-forget به‌روز می‌شوند.

خاموش کردن یک اتصال بدون حذف آن:

```http title="خاموش کردن"
POST /api/proxy/v1/integrations/woocommerce/enabled
Content-Type: application/json

{"enabled":false}
```

اتصال خاموش به هر تحویلی `404` می‌دهد، دقیقا همان پاسخی که یک توکن ناشناس می‌گیرد.

## بررسی امضا {#signatures}

طرح یکسان است: `base64(HMAC-SHA256(secret, rawBody))`، مقایسه در زمان ثابت با مقدار trim شده‌ی هدر. مقایسه‌ی زمان ثابت است چون این نقطه‌ی پایانی را هر کسی می‌تواند صدا بزند و مقایسه‌ی بایت‌به‌بایت، هر چند هزار درخواست یک نویسه از مقدار درست را لو می‌دهد.

| منبع | هدر امضا | هدر موضوع |
| --- | --- | --- |
| `shopify` | `X-Shopify-Hmac-Sha256` | `X-Shopify-Topic` |
| `woocommerce` | `X-WC-Webhook-Signature` | `X-WC-Webhook-Topic`، با بازگشت به `X-WC-Webhook-Resource` |
| `segment` | ندارد، به بخش [Segment](/docs/webhooks#segment) نگاه کنید | ندارد |
| `digikala`، `basalam`، `torob`، `zarinpal` | **هیچ هدری خوانده نمی‌شود**، بخش بعد | ندارد |

اگر `secret` ذخیره نشده باشد، هر تحویل `401` می‌گیرد. تنها استثنا Segment است.

سازوکار بازگشتی ووکامرس برای افزونه‌های قدیمی است: اگر `X-WC-Webhook-Topic` خالی باشد، موضوع از `X-WC-Webhook-Resource` به‌علاوه‌ی نقطه به‌علاوه‌ی `X-WC-Webhook-Event` (یا `updated` اگر آن هم نباشد) ساخته می‌شود. این چیزی است که فروشگاهی با افزونه‌ی دوساله واقعا می‌فرستد.

### چهار منبع ایرانی که از راه HTTP امضایشان تأیید نمی‌شود {#iranian-signature-gap}

> [!danger]
> **دیجی‌کالا، باسلام، ترب و زرین‌پال امروز از راه `POST /v1/hooks/{source}/{token}` کار نمی‌کنند.** این نقص شناخته‌شده است و اینجا نوشته شده تا کسی نصف روز دنبال تنظیم اشتباهی نگردد که وجود ندارد.

بدنه‌ی موضوع: تابعی که تصمیم می‌گیرد امضا را از کدام هدر بخواند، برای شاپیفای و ووکامرس هدر درست را برمی‌گرداند و **برای هر منبع دیگری رشته‌ی خالی**. هیچ شاخه‌ای برای این چهار منبع نوشته نشده. بررسی‌کننده‌ی امضا آن رشته‌ی خالی را می‌گیرد، یک `MAC` واقعی `base64` را با آن مقایسه می‌کند، هیچ‌وقت برابر نمی‌شود، و نتیجه این است:

```json title="آنچه واقعا برمی‌گردد"
{"status":"error","message":"signature mismatch"}
```

با کد `401`. همزمان `last_error` اتصال روی `bad signature` می‌نشیند و شمارنده‌ی `rejected` بالا می‌رود. اگر هم رمزی تنظیم نکرده باشید، دقیقا همان `401` و همان بدنه و همان `bad signature` را می‌گیرید: هندلر برای هر شکست بررسی امضا، دلیلش هر چه باشد، همین دو مقدار ثابت را می‌نویسد. دلیل `no shared secret configured` فقط به لاگ سرور می‌رود، پس هرچقدر هم آخرین خطای پنل را بخوانید این دو حالت از هم جدا نمی‌شوند.

توجه کنید که خود کتابخانه‌ی تبدیل، این چهار منبع را پشتیبانی می‌کند: تابع بررسی امضا برایشان همان `HMAC-SHA256` روی بدنه‌ی خام را می‌پذیرد، و تبدیل هر چهار منبع نوشته شده و تست دارد. **آنچه وجود ندارد، هدر خواندنی در سمت HTTP است.** تا وقتی این اضافه نشده، اگر یکی از این چهار منبع را لازم دارید، با ما تماس بگیرید.

## پاسخ‌ها {#responses}

| حالت | کد | بدنه |
| --- | --- | --- |
| منبع ناشناس، توکن ناشناس، یا اتصال **خاموش** | `404` | `{"status":"error","message":"unknown webhook"}` |
| بدنه خوانده نشد | `400` | `{"status":"error","message":"unreadable body"}` |
| امضا نخواند (هر منبعی جز Segment) | `401` | `{"status":"error","message":"signature mismatch"}` |
| اعتبارنامه‌ی Segment نخواند | `401` | `{"status":"error","message":"unauthorized"}` |
| بدنه قابل تبدیل نبود | `200` | `{"status":"ok"}` |
| تبدیل هیچ رویدادی تولید نکرد | `200` | `{"status":"ok"}` |
| هم گذرگاه و هم بافر دیسک شکست خوردند | `503` | `{"status":"error","message":"temporarily unavailable, please retry"}` |
| موفقیت | `200` | `{"status":"ok","accepted":3}` |

سه چیز در این جدول عمدی است.

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

بدنه‌ای که نمی‌شود از آن استفاده کرد `200` می‌گیرد و نه خطا. این پلتفرم‌ها هر پاسخ غیر `2xx` را روزها دوباره می‌فرستند و آن بدنه هرگز عوض نمی‌شود. دلیل شکست در `last_error` اتصال ثبت می‌شود تا صفحه‌ی پنل بتواند بگوید چرا چیزی نیامد.

`accepted` وقتی صفر باشد اصلا در پاسخ نمی‌آید، چون فیلد `omitempty` است. یعنی `{"status":"ok"}` یعنی صفر رویداد.

**رویدادهای وب‌هوک متر می‌شوند و روی صورتحساب می‌آیند**، به ازای هر رویداد تازه‌ی پذیرفته‌شده یکی. برخلاف رویدادهای on-site که اصلا شمرده نمی‌شوند. روی این مسیر سقف سهمیه بررسی نمی‌شود.

## جلوگیری از تکرار {#deduplication}

هر تبدیل یک `message_id` قطعی از شناسه‌ی خود آن پلتفرم می‌سازد. همه‌ی این پلتفرم‌ها دوباره می‌فرستند و بعضی‌شان با اصرار، و بدون این کار درآمد مشتری در نخستین اختلال شبکه دوبار شمرده می‌شود، آن هم روی عددی که بیشتر از همه نگاهش می‌کند.

| منبع | شکل شناسه |
| --- | --- |
| سفارش شاپیفای | `shopify:<event>:<order id>` |
| مشتری شاپیفای | `shopify:identify:<customer id>` |
| ووکامرس | `woocommerce:<event>:<order id>:<status>` |
| Segment | همان `messageId` خودش، و اگر نبود یکی مشتق‌شده |
| چهار منبع ایرانی | `<source>:<event>:<key>` که `key` نخستین مقدار موجود از `order_id`، `ref_id` یا `click_id` است و در نبودشان شناسه‌ی کاربر |

وضعیت سفارش داخل شناسه‌ی ووکامرس هست تا سفارشی که سه حالت را طی می‌کند سه رویداد باشد و در عین حال تلاش دوباره‌ی هر کدام گرفته شود.

شناسه‌ای که مقدارش `""` یا `"0"` باشد، `message_id` خالی تولید می‌کند، یعنی آن رویداد از تشخیص تکرار سود نمی‌برد.

رویدادهای تکراری در `accepted` شمرده نمی‌شوند و خطا هم نیستند: تلاش دوباره بعد از یک timeout حالت عادی است و نه بی‌قاعدگی.

## منبع‌ها {#sources}

هفت منبع، به همان ترتیبی که در پنل دیده می‌شوند. چهار پلتفرم ایرانی عمدا اول فهرست‌اند: انتخابگری که با شاپیفای شروع شود، که یک فروشنده‌ی ایرانی اصلا نمی‌تواند از آن استفاده کند، مثل محصولی خوانده می‌شود که برای جای دیگری ساخته شده است.

### دیجی‌کالا {#digikala}

وب‌هوک سفارش پنل فروشنده. فروشگاهی که فقط روی دیجی‌کالا می‌فروشد سایتی ندارد که SDK رویش بنشیند، پس این تنها مسیرش به داده‌ی سفارش است.

موضوع‌های پذیرفته‌شده: `order.created`، `order.confirmed` و موضوع خالی به `order_completed`؛ `order.cancelled` و `order.canceled` به `order_cancelled`؛ `order.returned` و `order.refunded` به `order_refunded`. هر چیز دیگری رد می‌شود و پاسخ `200` است.

```json title="بدنه‌ای که پذیرفته می‌شود"
{
  "order_id": 88213445,
  "status": "confirmed",
  "created_at": "2026-08-06 11:42:00",
  "total_price": 2450000,
  "customer": { "id": 5512, "mobile": "09123456789", "name": "علی" },
  "items": [
    { "product_id": 771, "title": "کفش رانینگ", "quantity": 1, "price": 2450000 }
  ]
}
```

ویژگی‌های رویدادی که ساخته می‌شود: `order_id`، `source`، `currency` که همیشه `IRR` است و هرگز تبدیل نمی‌شود، `revenue`، `item_count` که اینجا عدد است، `product_id` و `product_name` از نخستین قلم.

### باسلام {#basalam}

بازارگاه کالای دست‌ساز و محلی. موضوع‌ها: `order.paid`، `order.created` و موضوع خالی به `order_completed`؛ `order.cancelled` و `order.canceled` به `order_cancelled`.

```json title="بدنه‌ای که پذیرفته می‌شود"
{
  "id": 90112,
  "status": "paid",
  "created_at": "2026-08-06T11:42:00",
  "amount": 780000,
  "customer": { "id": 341, "mobile": "09123456789", "username": "ali_b" },
  "product": { "id": 4471, "title": "شمع دست‌ساز" }
}
```

ویژگی‌ها: `order_id`، `source`، `currency` برابر `IRR`، `revenue`، و در صورت وجود `product_id` و `product_name`.

### ترب {#torob}

موتور مقایسه‌ی قیمت. کلیک می‌فرستد و نه سفارش، و همین آن را برای فروشگاهی که ترافیکش از مقایسه می‌آید، بالای قیف می‌کند.

موضوع‌ها: خالی، `click` یا `referral`.

```json title="بدنه‌ای که پذیرفته می‌شود"
{
  "click_id": "trb_88a1",
  "product_id": 771,
  "title": "کفش رانینگ",
  "price": 2450000,
  "user_id": "trb_u_5512",
  "mobile": "09123456789",
  "created_at": "2026-08-06 11:42:00"
}
```

رویدادی که ساخته می‌شود همیشه `product_viewed` است و **هرگز سفارش نیست**. گزارش کردن یک ارجاع به‌عنوان خرید، هر عدد تبدیلی را که یک فروشگاه مقایسه‌محور نگاه می‌کند باد می‌کند. ویژگی‌ها: `source`، `click_id`، و در صورت وجود `product_id`، `product_name` و `price`.

### زرین‌پال {#zarinpal}

بازگشت درگاه پرداخت. حتی برای فروشگاهی که خودش `order_completed` می‌فرستد هم ارزش دارد: درگاه تنها طرفی است که می‌داند پرداخت واقعا تسویه شده، و «سفارش ثبت شد» با «پول رسید» به اندازه‌ی نرخ رهاکردن سبد فرق دارد، که در تجارت الکترونیک ایران کوچک نیست.

موضوع‌ها: خالی، `payment` یا `verify`.

```json title="بدنه‌ای که پذیرفته می‌شود"
{
  "authority": "A00000000000000000000000000123456789",
  "ref_id": 77112233,
  "amount": 245000,
  "status": "OK",
  "email": "ali@example.ir",
  "mobile": "09123456789",
  "order_id": "ORD-9",
  "created_at": "2026-08-06 11:42:00"
}
```

**فقط پرداخت تسویه‌شده رویداد می‌سازد.** مقدار `status` بعد از trim و تبدیل به حروف بزرگ باید خالی، `OK`، `100` یا `SUCCESS` باشد. هر چیز دیگری با `payment not settled: <status>` رد می‌شود و پاسخ `200` است. زرین‌پال شکست‌ها را هم گزارش می‌کند و ثبت کردن یک پرداخت ناموفق به‌عنوان سفارش، همان جایی است که گزارش درآمد از آنچه درگاه واقعا تسویه کرده بالاتر می‌رود.

`order_id` می‌تواند رشته یا عدد باشد، چون شناسه‌ی سفارش هر فروشگاه همان چیزی است که checkout خودش تولید می‌کند: برای یکی `"ORD-9"` و برای دیگری `55123`. اگر ساختار فقط یکی را می‌پذیرفت، decode کل بدنه شکست می‌خورد و یک پرداخت تسویه‌شده از دست می‌رفت.

**مقدار `amount` روی API نسخه‌ی چهار زرین‌پال به تومان است.** ما هر دو را ثبت می‌کنیم: `revenue` برابر `amount * 10` به ریال و `amount_toman` برابر خود `amount`. گزارشی که این دو را قاطی کند ده برابر خطا دارد و در هر دو حالت باورپذیر به نظر می‌رسد. ویژگی‌های دیگر: `source`، `ref_id`، `authority`، `currency` برابر `IRR`، و `order_id` وقتی مقدارش خالی یا `0` نباشد.

هویت در هر چهار منبع ایرانی به این ترتیب انتخاب می‌شود: **اول شماره‌ی موبایل** که به شکل `E.164` نرمال می‌شود، بعد ایمیل با حروف کوچک، بعد شناسه‌ی داخلی خود پلتفرم. شماره‌ی موبایل همان شناسه‌ای است که سیستم‌های خود یک فروشگاه ایرانی روی آن کلید می‌زنند و بیشترین شانس را دارد که به پرونده‌ای که همین‌جا هست بخورد. `name` به ویژگی `first_name` تبدیل می‌شود. اگر هیچ هویتی نباشد، کل بدنه با `no identity` رد می‌شود و پاسخ باز هم `200` است.

زمان‌های این چهار منبع **در تهران** تفسیر می‌شوند و نه در UTC. این پلتفرم‌ها زمان محلی بدون افست می‌فرستند، و خواندنش به‌عنوان UTC هر سفارش را سه ساعت و نیم جلو می‌اندازد، که یک سفارش صبح را در گزارش روز قبل می‌نشاند. قالب‌های پذیرفته‌شده `RFC3339`، `2006-01-02T15:04:05`، `2006-01-02 15:04:05` و `2006-01-02` هستند. زمان ناخوانا به «الان» تبدیل می‌شود و رویداد رد نمی‌شود: رویدادی با زمان کمی غلط از رویدادی که اصلا نیامده خیلی باارزش‌تر است.

### ووکامرس {#woocommerce}

فقط دو موضوع خوانده می‌شود: `order.created` و `order.updated`.

**وضعیت سفارش تعیین می‌کند چه رویدادی ساخته شود و نه موضوع.** ووکامرس به ازای هر تغییر وضعیت یک وب‌هوک می‌فرستد، و خواندن موضوع به تنهایی، سفارشی را که از `pending` به `processing` و بعد به `completed` می‌رود، سه خرید می‌کند.

| `status` | رویداد |
| --- | --- |
| `processing`، `completed` | `order_completed` |
| `cancelled`، `failed` | `order_cancelled` |
| `refunded` | `order_refunded` |
| `pending`، `on-hold` | `checkout_started` |
| هر چیز دیگری | رد می‌شود با `order.<status>` |

هویت: `billing.email` و بعد `billing.phone`. هیچ‌کدام نباشد، بدنه بی‌هویت است. رقم‌های فارسی داخل شماره‌ی تلفن به لاتین تا می‌شوند. `billing.city` به ویژگی `city` تبدیل می‌شود. مقدار `date_created_gmt` زمانی بدون منطقه است و همان‌طور خوانده می‌شود.

ویژگی‌ها: `order_id`، `order_number` (و اگر نبود، خود `order_id`)، `status`، `source`، و در صورت وجود اقلام `product_id`، `product_name` و `item_count` که اینجا **رشته** است. اگر `total` بزرگ‌تر از صفر باشد، `revenue` و `currency` هم می‌آیند و ارز پیش‌فرض `IRR` است.

### شاپیفای {#shopify}

| موضوع | رویداد |
| --- | --- |
| `orders/create`، `orders/paid` | `order_completed` |
| `orders/cancelled` | `order_cancelled` |
| `refunds/create` | `order_refunded` |
| `checkouts/create`، `checkouts/update` | `checkout_started` |
| `customers/create`، `customers/update` | یک identify |
| هر چیز دیگری | رد می‌شود، پاسخ `200` |

هویت به این ترتیب: **اول ایمیل، بعد تلفن، آخر شناسه‌ی خود پلتفرم.** ایمیل چیزی است که بقیه‌ی سیستم‌های مشتری روی آن کلید می‌زنند؛ شناسه‌ی پلتفرم پایدار است ولی فقط داخل همان پلتفرم معنی دارد، پس اول گذاشتنش همان آدم را روزی که از راه SDK هم بیاید، دو پرونده می‌کند. checkout مهمان بدون ایمیل و بدون تلفن، رویدادی نمی‌سازد و پاسخ `200` است.

ویژگی‌ها: `order_id`، `order_number`، `source`، `product_id` (یعنی SKU نخستین قلم و اگر نبود شناسه‌ی محصولش)، `product_name`، `item_count` که **رشته** است، `product_ids` که فهرست SKUها با کاما است، `checkout_url` وقتی بدنه `abandoned_checkout_url` داشته باشد، و در صورت مثبت بودن مبلغ، `revenue` و `currency` با پیش‌فرض `IRR`.

سفارشی با سه قلم کالا **یک** رویداد `order_completed` می‌سازد و نه سه تا. رویداد به ازای هر قلم، هر عدد درآمدی را سه برابر می‌کرد.

### Segment {#segment}

Segment امضا نمی‌کند. به‌جای آن، هدر `Authorization` با رمز ذخیره‌شده در زمان ثابت مقایسه می‌شود، پیشوند اختیاری `Bearer` به‌همراه فاصله تحمل می‌شود، و **این بررسی فقط وقتی انجام می‌شود که رمزی تنظیم شده باشد**، تا مشتری‌ای که رمز نگذاشته از اتصال خودش بیرون نیفتد.

هم یک شیء تنها پذیرفته می‌شود و هم شکل `{"batch":[...]}` که Segment در حجم بالا واقعا همان را می‌فرستد.

| `type` | نتیجه |
| --- | --- |
| `track` | رویداد track با همان `event` |
| `identify` | identify |
| `page` | page با `event` برابر `name` یا `page` |
| `screen` | screen با `event` برابر `name` یا `screen` |
| هر چیز دیگری | همان قلم دور ریخته می‌شود |

بدنه‌ای که نه `userId` دارد و نه `anonymousId` دور ریخته می‌شود. یک قلم خراب کل دسته را از بین نمی‌برد: Segment صدها تا با هم می‌فرستد و رد کردن کل دسته به خاطر یک ردیف بد، نودونه تای دیگر را به مشتری تحمیل می‌کند. `messageId` خود Segment به‌عنوان کلید تشخیص تکرار نگه داشته می‌شود، پس بازپخشی که از هر دو مسیر به ما برسد یک رویداد است و نه دو تا.

```bash title="فرستادن یک رویداد از راه اتصال Segment"
curl -s -X POST https://in.segmentic.net/v1/hooks/segment/yVJk3rQx7Pd1wNfZ0aLbCsTu \
  -H "Authorization: Bearer a-shared-secret" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "track",
    "event": "order_completed",
    "userId": "u_9137",
    "messageId": "seg_0f21",
    "timestamp": "2026-08-06T11:42:00Z",
    "properties": { "revenue": 2450000, "currency": "IRR" }
  }'
```

```json title="پاسخ"
{"status":"ok","accepted":1}
```

## رله‌ی رویداد: داده به بیرون {#relays}

همتای بیرونی وب‌هوک ورودی. رله رویدادها را همان‌طور که می‌رسند به نشانی شما POST می‌کند، تا CRM یا سیستم انبار شما لازم نباشد هر سی ثانیه API ما را poll کند.

این پنج مسیر هم روی کنترل‌پلین‌اند، کنار مسیرهای اتصال که بالاتر آمد، و روی میزبان مدیریتی هیچ مسیری برای رله وجود ندارد.

> [!danger]
> **کنترل‌پلین از اینترنت مسیردهی نشده است.** همین پنج مسیر روی `https://api.segmentic.net` به هندلر پیش‌فرض می‌افتند و `404 unknown_endpoint` می‌گیرند. تنها مسیر عمومی به آن‌ها، پروکسی سمت سرور خود پنل است روی `https://app.segmentic.net/api/proxy/v1/...` و با کوکی نشست کاربر واردشده. کلید `sk_seg_` به آن نمی‌رسد، پس ساختن رله و تلاش دوباره‌اش در صفحه‌ی رله‌های پنل انجام می‌شود. کاری که رله بعدش می‌کند، یعنی تحویل به نقطه‌ی پایانی شما، اصلا به هیچ APIای از ما نیاز ندارد.

| متد و مسیر | دسترسی لازم |
| --- | --- |
| `GET /v1/relays` | `settings.read` |
| `PUT /v1/relays` | `settings.write` |
| `DELETE /v1/relays/{id}` | `settings.write` |
| `GET /v1/relays/{id}/deliveries` | `settings.read` |
| `POST /v1/relays/{id}/retry` | `settings.write` |

```http title="ساختن یک رله"
PUT /api/proxy/v1/relays
Content-Type: application/json

{
  "name": "انبار",
  "url": "https://ops.example.ir/hooks/segmentic",
  "events": ["order_completed"],
  "filters": [{ "prop": "revenue", "op": "gte", "value": "5000000" }],
  "enabled": true,
  "secret": "a-relay-secret"
}
```

```json title="پاسخ"
{"id":4}
```

**فهرست `events` خالی یعنی همه‌ی رویدادها.** پیش‌فرض عمدا پرسروصداست، چون حالت دیگر، یعنی خالی به معنای هیچ، رله‌ای می‌سازد که به نظر تنظیم‌شده می‌آید و بی‌صدا هیچ کاری نمی‌کند، و کشف کردنش یک تیکت پشتیبانی خرج دارد.

فیلترها شرط روی ویژگی‌های رویدادند و شکلشان `{"prop","op","value"}` است. عملگرها: `eq`، `ne`، `contains`، `prefix`، `gt`، `gte`، `lt`، `lte`، `exists`، `missing`. حداکثر هشت فیلتر، چون بیشتر از آن دیگر تعریف یک سگمنت است و رله شیر آب با یک فیلتر است و نه موتور پرس‌وجو. **همه‌ی فیلترها باید بخورند و نه یکی از آن‌ها.** مقایسه با ویژگی‌ای که رویداد اصلا ندارد، نادرست است و خطا نیست. عددی که به شکل متن آمده باشد باز هم به‌عنوان عدد مقایسه می‌شود.

خطاهای اعتبارسنجی `400` می‌گیرند: نام خالی، نشانی خالی، نام رویداد خالی، عملگر ناشناس، فیلترهای زیادی. اگر نصب کلید مهروموم‌کردن نداشته باشد، پاسخ `503` است. خط ممیزی مقصد را ثبت می‌کند و هرگز رمز را.

بدنه‌ای که به نقطه‌ی پایانی شما می‌رسد عمدا کوچک و پایدار است و نه ساختار داخلی ما. سیستم گیرنده کد کسی دیگر است که ما نمی‌توانیم دوباره deploy کنیم، پس هر فیلد اینجا یک قول است:

```json title="آنچه نقطه‌ی پایانی شما می‌گیرد"
{
  "event": "order_completed",
  "user_id": "u_9137",
  "anonymous_id": "a_9f21c4",
  "timestamp": "2026-08-02T12:00:00Z",
  "properties": { "revenue": 2500000, "city": "تهران" },
  "message_id": "shopify:order_completed:450789469"
}
```

`user_id`، `anonymous_id` و `properties` وقتی خالی باشند نمی‌آیند. دو نگاشت ویژگی که ما داخل خودمان جدا نگه می‌داریم، اینجا دوباره یکی می‌شوند؛ دادن دو نگاشت به مشتری برای سرهم کردن، یعنی نشت کردن چیدمان انبار ما داخل کد او.

پخش کردن **بعد از** بادوام شدن رویدادها انجام می‌شود و هرگز قبلش. رله‌ای که برای رویدادی آتش کند که بعد نوشتنش شکست بخورد، به سیستم انبار مشتری از سفارشی خبر می‌دهد که این پلتفرم هیچ ردی از آن ندارد.

### امضای رله {#relay-signing}

اگر رمزی ذخیره کرده باشید، هر درخواست این دو هدر را می‌گیرد:

```http title="هدرهای امضا"
X-Segmentic-Timestamp: 1786032120
X-Segmentic-Signature: 4f1c9a2b...
```

امضا برابر `hex(HMAC-SHA256(secret, timestamp + "." + payload))` است. زمان داخل همان چیزی است که امضا می‌شود، تا بدنه‌ای که کسی ضبط کرده یک هفته بعد دوباره روی نقطه‌ی پایانی شما پخش نشود و باز هم درست تأیید شود.

هدرهای دیگر: `Content-Type: application/json` و `User-Agent: Segmentic-Relay/1`.

### تلاش دوباره {#relay-retries}

مهلت هر تحویل پانزده ثانیه است. حداکثر ده تلاش، با فاصله‌ی نمایی از ده ثانیه که روی یک ساعت سقف می‌خورد؛ مجموع کمی کمتر از دو ساعت است. سقف از خود منحنی مهم‌تر است: بدون آن، دهمین تلاش دو روز بعد می‌افتاد و تحویلی که دو روز دیر می‌رسد از تحویلی که هرگز نمی‌رسد بدتر است، چون سیستم مشتری تا آن موقع بدون آن حسابش را بسته است.

- کد `2xx` موفقیت است.
- کدهای `408` و `429` و هر `5xx` دوباره تلاش می‌شوند: نقطه‌ی پایانی بالاست و حالش بد است.
- **هر `4xx` دیگری دائمی است.** ده بار تکرار کردن یک `401` اعتبارنامه را معتبر نمی‌کند و فقط لاگ خطای مشتری را ده برابر شلوغ‌تر می‌کند.
- نشانی‌ای که parse نشود، و نشانی‌ای که نگهبان SSRF ردش کند، هم دائمی‌اند. نگهبان قبل از درخواست و **دوباره در هر پرش redirect** بررسی می‌کند، چون میزبان عمومی‌ای که به `127.0.0.1` ریدایرکت می‌دهد، بررسی‌ای را که فقط روی نخستین نشانی انجام شده دور می‌زند.

۵۰۰ بایت نخست بدنه‌ی یک شکست برای صفحه‌ی گزارش تحویل نگه داشته می‌شود، چون نقطه‌ی پایانی‌ای که `400` می‌دهد معمولا در بدنه می‌گوید چرا، و همان یک جمله کل ارزش آن صفحه است.

`POST /v1/relays/{id}/retry` هر تحویل مرده را دوباره در صف می‌گذارد و `{"requeued": N}` برمی‌گرداند. نقطه‌ی پایانی‌ای که یک ساعت بد تنظیم شده بود، پشته‌ای از تحویل مرده می‌گذارد و راه دیگر این است که از مشتری بخواهیم رویدادها را از سمت خودش بازپخش کند، که نمی‌تواند، چون رویدادها مال ما بودند.

## لینک کوتاه {#short-links}

فقط برای یک کانال و یک دلیل: پیامک به ازای هر بخش صورتحساب می‌شود، هر بخش فارسی هفتاد نویسه است، و نشانی یک کمپین مرتب شصت نویسه از آن را می‌گیرد. لینک اغلب گران‌ترین چیز داخل پیام است و یک جمله‌ی یک کلمه بلندتر، صورتحساب کل مخاطب را دو برابر می‌کند.

مسیر `GET /s/{code}` است. پیشوند عمدا یک نویسه است: در هر پیامکی که کوتاه‌کننده بازنویسی می‌کند حاضر است و هر نویسه در پیشوند، یک نویسه کمتر برای خود پیام.

`code` هفت نویسه از الفبای `base32` سبک Crockford است: `0123456789ABCDEFGHJKMNPQRSTVWXYZ`. حرف‌های I و L و O و U عمدا در آن نیستند. یک لینک کوتاه با صدای بلند خوانده می‌شود، از روی اسکرین‌شات تایپ می‌شود و پشت تلفن دیکته می‌شود، و هر کدام از آن چهار حرف یک تیکت پشتیبانی است که با «می‌گوید صفحه پیدا نشد» شروع می‌شود.

کد **مشتق می‌شود و صادر نمی‌شود**:

```text title="فرمول کد"
code = base32(sha256(tenant_id || 0x00 || target))[:7]
```

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

بازنویسی فقط روی مسیر پیامک انجام می‌شود و در آنجا هم:

- **هرگز روی خط خدماتی.** آنجا متن باید با الگویی که نزد اپراتور ثبت شده مو به مو بخواند، و بازنویسی هر بخشی از آن باعث می‌شود درگاه ارسال را رد کند.
- نگاشت کد به مقصد **قبل** از رفتن پیام ذخیره می‌شود، و اگر ذخیره شکست بخورد لینک بلند سر جایش می‌ماند. کدی که در inbox کسی چاپ شده و به هیچ‌جا نمی‌رسد، از لینکی که یک بخش بیشتر خرج دارد بدتر است.
- فقط چیزی که `http` یا `https` باشد. یک deep link مثل `myapp://order/12345` را خود اپ تجزیه می‌کند و یک ریدایرکت وب مسیریابی‌اش را می‌شکند.
- و فقط وقتی نتیجه واقعا کوتاه‌تر باشد. برای مشتری‌ای که دامنه‌ی خودش از قبل کوتاه است، بازنویسی پیام را بلندتر می‌کرد.

ذخیره کردن نگاشت `ON CONFLICT DO NOTHING` است و عمدا **مقصد را به‌روز نمی‌کند**: اجازه دادن به یک ارسال بعدی که کدی موجود را به جای دیگری نشانه بگیرد، یعنی عوض کردن بی‌صدای مقصد لینکی که همین حالا در inbox کسی نشسته است.

راه‌اندازی: متغیر `SHORT_LINK_BASE` را تنظیم کنید و همان دامنه را به collector اشاره بدهید، چون `/s/{code}` آنجا سرو می‌شود. تا وقتی تنظیم نشده، کوتاه‌کننده خاموش است و پیام‌ها دقیقا مثل امروز می‌روند.

### آنچه کلیک ثبت می‌کند {#click-tracking}

ریدایرکت `302` است و **هرگز `301` نیست**. ریدایرکت دائمی را خود گوشی کش می‌کند و هر بار بعد از اولی هیچ‌وقت به ما نمی‌رسد، که شمارش کلیک را بدون اینکه چیزی بگوید، به شمارش «نخستین بار» تبدیل می‌کند.

ترتیب کار: یک خواندن روی کلید اصلی، بعد ریدایرکت، و کلیک **بعد از** نوشتن پاسخ در یک goroutine روی زمینه‌ای جدا از درخواست با مهلت پنج ثانیه شمرده می‌شود. زمینه‌ی خود درخواست همان لحظه‌ای که پاسخ نوشته می‌شود لغو می‌شود و به‌روزرسانی تقریبا همیشه برمی‌گشت. شکست شمردن فقط در سطح warn لاگ می‌شود.

کد ناشناس، و همچنین خطای خود جست‌وجو، `404` ساده می‌گیرند و نه صفحه‌ی خطا. کد ناشناس تقریبا همیشه کدی است که اشتباه تایپ شده و چیز مفیدی برای گفتن به کسی که هفت نویسه را از روی اسکرین‌شات خوانده وجود ندارد.

آنچه ثبت می‌شود دقیقا این است و بیشتر از این نیست:

```sql title="کل چیزی که نوشته می‌شود"
UPDATE short_links SET clicks = clicks + 1, last_click = $2 WHERE code = $1
```

یعنی یک شمارنده و یک زمان آخرین کلیک، روی ردیف خود لینک. **نه شناسه‌ی فرد، نه IP، نه user agent.**

اینجا هیچ توکنی نیست و نمی‌تواند باشد. هر نقطه‌ی پایانی رهگیری دیگری با `sg_mid` و یک امضا صدا زده می‌شود و بدون آن‌ها رد می‌شود، چون نرم‌افزاری صدایش می‌زند که خودمان لینک را به آن داده‌ایم. این یکی را گوشی‌ای صدا می‌زند که چیزی جز آن هفت نویسه‌ی چاپ‌شده در پیام ندارد.

پس مدل انتساب اینجا **به ازای لینک و به ازای کمپین است و هرگز به ازای فرد نیست**. شصت هزار گیرنده‌ی یک کمپین همان یک کد را دارند. برای پوش و ایمیل مدل به ازای فرد سر جایش هست و بهتر است: پارامترهای `sg_mid` و `sg_t` روی لینک نوشته می‌شوند، فرد روی سایت خود مشتری فرود می‌آید و SDK‌ای که از قبل آنجاست ورودش را گزارش می‌کند. برای ایمیل هیچ نقطه‌ی پایانی ریدایرکتی وجود ندارد، چون یک پرش اضافه‌ی ما می‌توانست شکست بخورد، دامنه‌ای می‌داد که بشود مسدودش کرد، و بدتر از همه هر کلیک اسکنر ضدبدافزار شرکتی را می‌شمرد که روی یک فهرست کسب‌وکاری می‌تواند بیشتر کلیک‌ها باشد.

> [!danger]
> **هیچ نقطه‌ی پایانی و هیچ صفحه‌ای این شمارنده را به شما نشان نمی‌دهد.** تابع خواندن کلیک‌های یک کمپین در لایه‌ی انبار نوشته شده و در کل درخت کد هیچ فراخوانی ندارد؛ نه مسیری در API آن را می‌خواند و نه صفحه‌ای در پنل نشانش می‌دهد. کلیک‌ها در ستونی شمرده می‌شوند که امروز قابل خواندن نیست.

## چه چیزی امروز نیست {#not-built}

اینجا فقط چیزهایی می‌آیند که کد شما را عوض می‌کنند: کاری که دنبالش می‌گردید و پیدا نمی‌کنید، یا کاری که باید جور دیگری انجامش بدهید. تصمیم‌هایی که عمدی‌اند، مثل نبودن ریدایرکت کلیک ایمیل یا برنگشتن رمز ذخیره‌شده، همان‌جایی توضیح داده شده‌اند که تصمیم گرفته شده‌اند.

- **تأیید امضای دیجی‌کالا، باسلام، ترب و زرین‌پال از راه HTTP.** بخش [چهار منبع ایرانی](/docs/webhooks#iranian-signature-gap).
- **خواندن کلیک لینک کوتاه.** شمارنده وجود دارد و هیچ راهی برای دیدنش نیست.
- **چرخاندن توکن وب‌هوک.** ذخیره‌ی دوباره توکن را عوض نمی‌کند و مسیر دیگری هم برای عوض کردنش نیست. اگر توکنی لو رفت، اتصال را خاموش کنید و با ما تماس بگیرید.
- **مسیرهای مدیریتی اتصال و رله روی میزبان مدیریتی.** این مسیرها فقط روی کنترل‌پلین داشبورد ثبت شده‌اند و آن شنونده از اینترنت مسیردهی نشده است، پس کلید `sk_seg_` به هیچ‌کدامشان نمی‌رسد. راه ورود، پروکسی خود پنل است.

---

# داده‌های شخصی: خروجی گرفتن و حذف

> کاری که وقتی کاربری می‌خواهد داده‌اش را ببیند یا پاک کند باید بکنید، و کاری که سگمنتیک خودش می‌کند.
> https://segmentic.net/docs/privacy

درخواست حذف را نمی‌شود از API فرستاد. همه‌ی مسیرهای `/v1/privacy/*` و `/v1/settings/retention` روی گوش‌دهنده‌ی کنترلی پنل‌اند، که عمداً از بیرون شبکه‌ی داخلی قابل آدرس‌دهی نیست. روی `api.segmentic.net` هیچ نقطه‌ی پایانی حریم خصوصی وجود ندارد. اگر می‌خواهید درخواست‌های کاربرانتان خودکار به سگمنتیک برسد، امروز چنین راهی نیست و باید یک آدم آن‌ها را در پنل ثبت کند.

> نمودار: مرز اطلاعات مشتریان در شیوه استقرار، دسترسی، حذف و ثبت عملیات سگمنتیک

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

## وقتی کسی می‌پرسد چه داده‌ای از من دارید {#access}

هیچ نقطه‌ی پایانی «همه‌ی داده‌ی این آدم را بده» وجود ندارد. برای جمع‌کردن جواب یک درخواست دسترسی، پنج مسیر جدا هست و همه دسترسی `profile.read` می‌خواهند:

| مسیر | چه می‌دهد | سقف |
|---|---|---|
| `GET /v1/profiles/{user_id}` | پرونده: ویژگی‌ها و ستون‌های محاسبه‌شده | یک پرونده |
| `GET /v1/profiles/{user_id}/timeline` | رویدادهای خود کاربر | ۱۰۰ رویداد آخر |
| `GET /v1/profiles/{user_id}/messages` | چه فرستادیم و چه شد، شامل ارسال‌هایی که عمداً انجام نشد | ۵۰ سطر آخر |
| `GET /v1/profiles/{user_id}/devices` | دستگاه‌های نصب‌شده و وضعیت پوش | همه |
| `GET /v1/profiles/{user_id}/preferences` | تنظیم کانال و موضوع | همه |

سقف ۱۰۰ رویداد یعنی تایم‌لاین، تاریخچه‌ی کامل نیست. اگر آن آدم هزار رویداد دارد، این مسیر جواب کاملی به یک درخواست دسترسی نمی‌دهد و راه دیگری هم برای گرفتن بقیه‌اش از API نیست.

خروجی `kind` برابر `profiles` کل حساب را می‌دهد، نه یک نفر را. فیلتری برای یک شناسه ندارد. یعنی ساختن پاسخ یک درخواست دسترسی، کار دستی است: این پنج صفحه را باز کنید و بخوانید.

سطرهای «ارسال نشد» در تاریخچه‌ی پیام، نیمه‌ی باارزش‌تر آن فهرست‌اند. «چیزی نفرستادیم چون شما در تاریخ فلان از این موضوع لغو اشتراک کردید» جواب خود شکایت است، و فقط به این دلیل وجود دارد که مسیر ارسال، امتناع‌ها را به همان دقت ارسال‌ها ثبت می‌کند.

## فایل کردن درخواست حذف {#erasure}

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

| مسیر | دسترسی |
|---|---|
| `GET /v1/privacy/erasures` | `profile.read` |
| `GET /v1/privacy/erasures/{id}` | `profile.read` |
| `POST /v1/privacy/erasures` | `profile.write` |
| `POST /v1/privacy/erasures/{id}/reject` | `settings.write` |
| `GET /v1/settings/retention` | `settings.read` |
| `PUT /v1/settings/retention` | `settings.write` |

فایل کردن یک حذف، نام یک آدم را می‌برد، پس همان جفت دسترسی را می‌گیرد که هر مسیر دیگر رسیدن به یک فرد می‌گیرد. نگه‌داری از جنس دیگری است: یک عدد که تعیین می‌کند چه مقدار از تاریخچه‌ی خود حساب زنده می‌ماند، پس `settings.write` است و فقط دست مالک و ادمین. بازاریابی که می‌تواند به دو میلیون نفر پیام بدهد، همچنان نمی‌تواند تصمیم بگیرد رویدادهای پارسال دیگر وجود نداشته باشند.

رد کردن یک درخواست هم `settings.write` است، چون امتناع از اجرای یک درخواست قانونی، تصمیمی درباره‌ی حساب است نه درباره‌ی آن شخص.

### شکل درخواست {#erasure-request}

درخواست‌های این بخش را با `curl` نمی‌شود زد. آنچه پایین می‌آید، همان درخواستی است که پنل روی کنترل‌پلین می‌فرستد، و اینجا آمده تا بدانید دقیقاً چه چیزی ثبت می‌شود و پاسخ چه شکلی است.

```http
POST /v1/privacy/erasures HTTP/1.1
Content-Type: application/json

{"kind": "phone", "identifier": "+98 912 345 6789"}
```

`kind` یکی از `user_id` یا `email` یا `phone` است و اگر ننویسید `user_id` فرض می‌شود. شناسه قبل از هش‌شدن نرمال می‌شود، نه اینکه همان‌طور که آمده باور شود: دو املای یک شماره که جدا هش شوند، دو تعهد برای یک آدم با دو مهلت می‌سازند و دومی هرگز با جست‌وجوی اولی پیدا نمی‌شود.

- `user_id` فقط فضای اضافه‌اش گرفته می‌شود. کلید خود شماست و ممکن است بزرگی و کوچکی حروف در آن معنا داشته باشد.
- `email` کوچک می‌شود و باید `@` داشته باشد که نه اول باشد و نه آخر.
- `phone` فقط رقم‌هایش نگه داشته می‌شود، با ارقام فارسی و عربی هم کار می‌کند. چهار شکل `09123456789` و `+989123456789` و `00989123456789` و `9123456789` به یک شکل می‌رسند. حذفی که سه شکل از این چهار تا را جا می‌انداخت، موفقیت گزارش می‌کرد و آن آدم کاملاً در دسترس می‌ماند.

پاسخ ۲۰۲ است:

```json
{
  "id": 41,
  "subject_hash": "Q1p4bF9y...",
  "subject_ref": "09123456789",
  "subject_kind": "phone",
  "status": "pending",
  "requested_by": "member:12",
  "requested_at": "2026-08-07T09:20:00Z",
  "due_at": "2026-09-06T09:20:00Z",
  "report": {
    "profiles": 0, "events": 0, "messages": 0, "devices": 0, "consent": 0,
    "journey_state": 0, "identities": 0, "suppressions_kept": 0,
    "segment_membership": 0
  },
  "attempts": 0,
  "status_label": "در صف",
  "kind_label": "شمارهٔ همراه",
  "overdue": false,
  "removed_total": 0,
  "days_remaining": 30,
  "requested_label": "۱۶ مرداد ۱۴۰۵"
}
```

خطاها:

| حالت | کد |
|---|---|
| شناسه خوانا نیست، یا `kind` ناشناخته است | ۴۰۰ |
| برای همین آدم یک درخواست باز وجود دارد | ۴۰۹ |
| کلید هش روی این نصب تنظیم نشده | ۵۰۳ |

آن ۵۰۳ عمدی است. هش، خود سند ماجراست؛ بدون کلید جایی برای گذاشتن سند دائمی نیست، پس درخواست رد می‌شود به‌جای اینکه چیزی ثبت شود که بعداً نمی‌شود درباره‌اش گزارش داد. خواندن صف در همین حالت هم کار می‌کند: نصبی که نمی‌تواند درخواست تازه بگیرد، باید بتواند آنچه در دفتر است را نشان بدهد.

### صف و مهلت {#erasure-queue}

`due_at` هنگام درج، سی روز بعد از `requested_at` نوشته می‌شود و بعدها تغییر سیاست، مهلت‌های در جریان را جابه‌جا نمی‌کند. `overdue` سمت سرور حساب می‌شود، نه در مرورگر: «آیا آن ماه گذشته است» نباید به ساعت لپ‌تاپی که به صفحه نگاه می‌کند وابسته باشد.

```http
GET /v1/privacy/erasures?limit=50&offset=0 HTTP/1.1
```

```json
{"erasures": [], "total": 0, "overdue": 0}
```

`overdue` جدا از فهرست می‌آید تا صفحه بتواند با آن شروع کند. فهرستی که بر اساس تاریخ مرتب شده، همان یک درخواستی را که از مهلتش گذشته، وسط بقیه دفن می‌کند. `limit` بین ۱ تا ۲۰۰ است و خارج از آن به ۵۰ برمی‌گردد.

پنج وضعیت وجود دارد: `pending`، `running`، `completed`، `failed`، `rejected`. جاروکش هر یک دقیقه سراغ صف می‌آید و تا ۵۰ کار پشت‌سرهم برمی‌دارد، تا میز پشتیبانی که یکشنبه چهل درخواست ثبت کرده، چهل دقیقه منتظر چهلمی نماند. یک درخواست ناموفق تا ۵ بار دوباره تلاش می‌شود.

اگر هیچ‌کس با آن شناسه پیدا نشود، درخواست **کامل** ثبت می‌شود، نه ناموفق: چیزی از او نیست که پاک شود، و درخواستی که تا ابد روی شناسه‌ای که هرگز اینجا نبوده تلاش کند، از مهلت قانونی‌اش رد می‌شود و شبیه نقض تعهد به نظر می‌رسد.

جاروکش عمداً باینری جداگانه‌ای است. هر کاری که می‌کند یا یک mutation روی ClickHouse است یا یک حذف بزرگ روی Postgres، و هر دو دقیقاً همان ظرفیت دیسک و ادغامی را می‌خواهند که مسیر ورود داده لازم دارد. ضمناً همان باینری‌ای است که یک اپراتور نگران می‌تواند متوقفش کند، بدون اینکه ارسال پیام متوقف شود.

### رد کردن یک درخواست {#erasure-reject}

```http
POST /v1/privacy/erasures/41/reject HTTP/1.1
Content-Type: application/json

{"reason": "این حساب تحت نگه‌داشت قانونی پرونده‌ی فلان است"}
```

`reason` باید دست‌کم ۱۰ نویسه باشد، وگرنه ۴۰۰. امتناعی که دلیلش نوشته نشده، اولین چیزی است که یک ممیز درباره‌اش می‌پرسد، و «اپراتور چیزی ننوشت» جوابی نیست که یک کنترل‌کننده بتواند بدهد.

فقط درخواستی که `pending` یا `failed` است رد می‌شود؛ باقی حالت‌ها ۴۰۹. رد کردن، `subject_ref` را هم پاک می‌کند: درخواست ردشده هم نباید به انبار شناسه تبدیل شود. خود ردشدن حذف نمی‌شود، ثبت می‌شود، چون امتناعی که ردی از خودش نمی‌گذارد، از درخواستی که کسی نخوانده قابل تشخیص نیست.

```json
{"id": 41, "status": "rejected"}
```

## چه چیزی پاک می‌شود {#what-is-deleted}

ترتیب، خود قرارداد است:

1. اول آدرس ایمیل در فهرست مسدود ثبت می‌شود، تا اگر کار وسط راه شکست، آن آدم دست‌کم محافظت شده باشد نه فقط نصفه‌حذف.
2. بعد Postgres، در یک تراکنش، تا وضعیت عملیاتی یا کاملاً رفته باشد یا کاملاً مانده.
3. آخر ClickHouse، چون mutation ناهمگام است و در تراکنش هیچ‌کس نمی‌گنجد.

از Postgres این جدول‌ها روی `user_id` پاک می‌شوند: `devices`، `inapp_messages`، `journey_instances`، `journey_timers`، `campaign_timers`، `message_log`، `messenger_identities`، `topic_consent`، `user_consent`، `webpush_subscriptions`. توکن‌های دستگاه اول از همه می‌روند، چون به `device_id` وصل‌اند نه به کاربر و بعد از رفتن دستگاه‌ها چیزی برای پیوستنشان نمی‌ماند و تا ابد به آن‌ها پوش فرستاده می‌شود.

این فهرست دستی نوشته شده و از کاتالوگ پایگاه داده کشف نمی‌شود، چون `information_schema` جدول‌های `audit_log` و `memberships` و `sessions` را هم برمی‌گرداند که `user_id` آن‌ها عددی است و یکی از کارمندان خود مشتری را نام می‌برد. حذفی که روی نام ستون تطبیق می‌داد، حساب کارمندی را پاک می‌کرد که تصادفاً شناسه‌اش با مشتری حذف‌شده یکی بود.

از ClickHouse: `profiles`، `events`، `segment_members`، `daily_user_stats`، `engagement`، `message_touch`. `daily_user_stats` فقط شمارش دارد، ولی روی `user_id` کلید خورده، پس هر سطرش این جمله است که «این آدم در این روز وجود داشت»، و همان چیزی است که حذف باید بردارد. `engagement` و `message_touch` دفترهای انتساب‌اند: «این آدم آن پیام را باز کرد» به همان اندازه‌ی خود خرید، حقیقتی درباره‌ی اوست.

بعد از آن، تاریخچه‌ی پیش از ورود: رویدادهایی که `user_id` خالی دارند و فقط از راه `anonymous_id`های داخل `identity_map` قابل پیدا شدن‌اند. این کار **قبل** از پاک‌کردن `identity_map` انجام می‌شود و ترتیبش تمام ماجراست. نسخه‌ی اول این کد نقشه را اول پاک می‌کرد، پس زیرپرسمان به هیچ سطری نمی‌خورد، حذف چیزی برنمی‌داشت و موفقیت گزارش می‌کرد: تاریخچه‌ی گشت‌وگذار آن آدم پیش از ورود به حساب، که بخش بزرگی از معنی «مرا فراموش کن» است، از هر حذفی روی این پلتفرم بی‌صدا جان سالم به در می‌برد. یک تست یکپارچگی گرفتش.

آخرین قدم خود `identity_map` است، چون اندیس همه‌ی چیزهای بالاست.

هر mutation با `mutations_sync = 2` اجرا می‌شود، یعنی تا وقتی همه‌ی رپلیکاها تمام نکنند برنمی‌گردد. کندتر است و تنها تنظیمی است که زیر آن، «انجام شد» یعنی انجام شد؛ حالت پیش‌فرض به‌محض **صف‌شدن** mutation برمی‌گردد و گزارش را در حالی موفق می‌کرد که سطرها هنوز سر جایشان‌اند.

گزارش، شمارش است نه سطر. گزارشی که فهرست می‌کرد چه چیزی را پاک کرده، خودش نسخه‌ای از داده‌ی پاک‌شده بود، داخل همان جدولی که تمام هدفش ثبت این است که آن داده دیگر نیست.

هیچ نصب منتشرشده‌ای بدون انبار داده اجرا نمی‌شود: هم API و هم janitor موقع بالا آمدن یک اتصال ClickHouse باز می‌کنند و اگر جواب ندهد بالا نمی‌آیند. خود لایه‌ی ذخیره شاخه‌ی «بدون انبار» را دارد، و این شاخه بی‌صداست نه اینکه خودش را توضیح بدهد: قدم ClickHouse را رد می‌کند و `profiles` و `events` و `identities` و `segment_membership` را روی صفر می‌گذارد، یعنی همان گزارشی که یک انبار جست‌وجوشده و خالی هم می‌داد. گزارش ۹ شمارنده است و فیلدی برای این تفاوت ندارد، پس اینکه آن قدم اجرا شده یا نه، حقیقتی درباره‌ی استقرار است نه چیزی که بشود از گزارش خواند.

## چه چیزی می‌ماند، و چرا {#what-is-kept}

> [!danger]
> حذف، `user_consent` و `topic_consent` را پاک می‌کند. یعنی لغو اشتراک پوش و پیامک و موضوع‌های آن آدم، همراه خودش می‌رود. فقط ایمیل با یک سطر مسدودسازی دائمی محافظت می‌شود. اگر فردا همان شماره را دوباره وارد کنید، آن آدم دوباره با پیامک قابل دسترسی است، مگر اینکه قبلاً با پیام «لغو» جواب داده باشد، که آن یکی روی خود شماره ثبت می‌شود و دست‌نخورده می‌ماند.

بعد از یک حذف کامل، این‌ها روی سیستم باقی می‌مانند:

- **سطر خود درخواست.** `subject_hash` و `subject_kind` و `requested_by` و تاریخ‌ها و شمارش‌های گزارش می‌مانند؛ `subject_ref`، یعنی شناسه‌ی خواندنی، لحظه‌ی تمام‌شدن کار خالی می‌شود. نگه‌داشتن شناسه بعد از پایان کار یعنی جدولی که حذف را ثبت می‌کند، خودش سابقه‌ی آدمی است که خواسته حذف شود.
- **آدرس ایمیل آن آدم، برای همیشه.** سطر مسدودسازی با دلیل `erasure` نوشته می‌شود و تاریخ انقضایش `NULL` است، هرچه قبلاً بوده. این را صریح می‌گوییم چون در سند حقوقی مهم است: سگمنتیک بعد از حذف، همچنان آدرس ایمیل را نگه می‌دارد. بدون آن، سطر برگشتی همان آدرس روزی منقضی می‌شد و ارسال به کسی که خواسته فراموش شود، دوباره مجاز می‌شد.
- **شماره‌ای که قبلاً «لغو» فرستاده.** جدول لغو پیامک روی خود شماره کلید خورده، نه روی شناسه‌ی کاربر، و حذف به آن دست نمی‌زند. عمدی است: پاسخ از یک گوشی می‌آید و آدم پشتش ممکن است به هیچ پرونده‌ای نخورد، یا به چند پرونده بخورد، یا به پرونده‌ای بخورد که فردا پاک و دوباره وارد می‌شود. حذف روی این سطر یعنی همان سه حالت، مسدودسازی را از بین می‌برد.
- **این جدول‌ها که حذف اصلاً سراغشان نمی‌رود:** امتیاز درگیری و امتیاز ریزش، هم در Postgres و هم در نسخه‌ی آینه‌شان در ClickHouse؛ پاسخ‌های نظرسنجی درون‌سایتی؛ صف تحویل رله، که یک نسخه‌ی JSON از خود رویداد را نگه می‌دارد. هیچ‌کدام سطر کاربر حذف‌شده را پاک نمی‌کنند و در شمارش‌های گزارش هم نمی‌آیند.
- امتیاز RFM و عضویت شرط‌های ورود سناریو، هر بار از صفر بازنویسی می‌شوند، پس سطر آن آدم در اولین محاسبه‌ی بعدی خودبه‌خود می‌رود.
- **شمارنده‌ی روزانه‌ی کاربران فعال تهران**، که حالت تجمیعی است نه سطر آدم‌ها. بازنویسی نمی‌شود، پس شخص حذف‌شده همچنان داخل شمارش متمایز روزهای گذشته حساب می‌شود.
- **فایل‌های خروجی که قبلاً ساخته شده‌اند.** حذف به آن‌ها دست نمی‌زند. طبق ساعت هفت‌روزه‌ی خودشان پاک می‌شوند. اگر کسی قبلاً خروجی گرفته و روی لپ‌تاپش دارد، آن نسخه از دسترس ما بیرون است.

خط لاگ برنامه، شناسه‌ی درخواست و نقش کسی که ثبتش کرده را می‌نویسد و **هرگز خود شناسه را نمی‌نویسد**. آن خط، همان رد ممیزی است و ردی که نام آن آدم را ببرد، نسخه‌ای است از دقیقاً همان چیزی که قرار است پاک شود.

هش، HMAC روی شناسه‌ی حساب و شناسه‌ی نرمال‌شده است، با کلیدی که از کلید مخفی نصب گرفته می‌شود. کلیددار بودنش تمام نکته است: یک آدرس ایمیل آن‌قدر آنتروپی ندارد که از SHA-256 خام جان سالم به در ببرد، و جدولی از هش‌های ساده را با یک واژه‌نامه در چند دقیقه می‌شود برگرداند، یعنی جدولی از آدرس‌های ایمیل با یک کلاه. شناسه‌ی حساب داخل خود MAC است، پس یک آدرس در دو حساب، دو توکن متفاوت می‌سازد؛ بدون آن، یک جدول هش لو رفته به یک مشتری اجازه می‌داد بفهمد آدم مشخصی مشتری آن یکی هم هست یا نه.

## نگه‌داری {#retention}

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

```http
GET /v1/settings/retention HTTP/1.1
```

```json
{
  "policy": {
    "events_days": 365,
    "messages_days": 180,
    "bounces_days": 90,
    "inactive_profile_days": 0,
    "last_swept_at": "2026-08-06T02:14:00Z",
    "updated_at": "2026-05-02T11:00:00Z",
    "updated_by": "member:1"
  },
  "min_days": 30,
  "max_days": 3650
}
```

```http
PUT /v1/settings/retention HTTP/1.1
Content-Type: application/json

{"policy": {"events_days": 365, "messages_days": 180,
            "bounces_days": 90, "inactive_profile_days": 0}}
```

| فیلد | چه چیزی را می‌برد |
|---|---|
| `events_days` | رویدادهای خام رفتاری |
| `messages_days` | دفتر ارسال: به چه کسی، کی، و چرا نفرستادیم |
| `bounces_days` | گزارش‌های برگشتی و شکایت |
| `inactive_profile_days` | پرونده‌هایی که از آن زمان دیده نشده‌اند |

**صفر یعنی برای همیشه، روی هر چهار فیلد.** پیش‌فرض درست برای پلتفرمی است که نباید داده‌ی مشتری را به‌خاطر جاماندن یک فیلد پاک کند: حذف همیشه انتخابی است، و سیاست خالی، سیاستی نیست که تصادفاً چیزی پاک نکند، سیاستی است که عمداً چیزی پاک نمی‌کند.

هر مقدار غیرصفر باید دست‌کم ۳۰ روز و حداکثر ۳۶۵۰ روز باشد. مقدار خارج از این بازه **رد می‌شود، نه اینکه به مرز نزدیک شود**. بریدن بی‌صدا، همان چیزی است که باعث می‌شود کسی باور کند حدی گذاشته که نگذاشته، و اینجا آن باور درباره‌ی این است که داده‌ی مشتریانش چقدر زنده می‌ماند.

کف ۳۰ روز به‌خاطر سختی فنی نیست. زیر حدود یک ماه، حساب نمی‌تواند به «ماه گذشته چه شد» جواب بدهد، هر گزارش ماه‌به‌ماه خالی است، و آن قسمتی که واقعاً تیکت پشتیبانی می‌سازد: پنجره‌ی انتساب یک کمپین از رویدادهایی که با آن‌ها سنجیده می‌شود عمر بیشتری دارد، پس کمپین برای خریدهای واقعی، صفر تبدیل گزارش می‌کند. سقف ۳۶۵۰ روز هم لبه‌ی بیرونی هر تعهد نگه‌داری سوابق تجاری در ایران است؛ بعد از آن، عدد دیگر سیاست نیست، فیلدی است که فراموش شده.

جاروکش هر ۱۵ دقیقه دنبال حسابی می‌گردد که باید جارو شود، هر حساب حداکثر یک بار در ۲۴ ساعت، و هر پاس حداکثر ۵ حساب. جاروی ناموفق `last_swept_at` را نمی‌نویسد تا پاس بعدی دوباره امتحان کند؛ علامت‌زدن جاروی شکست‌خورده به‌عنوان انجام‌شده، همان راهی است که نگه‌داری یک حساب بی‌صدا از کار می‌افتد.

روی رویدادها، هر ماه که کاملاً منقضی شده با `DROP PARTITION` می‌رود، که فقط فایل‌ها را جدا می‌کند و تقریباً رایگان است. جدول رویدادها بر اساس (شناسه‌ی حساب، ماه) پارتیشن شده و شناسه‌ی حساب عمداً اول است تا همین ممکن باشد. فقط ماهی که مرز روی آن افتاده mutation واقعی می‌خورد، و آن یکی دقیق است، تا سیاست همان تعداد روزی را که می‌گوید معنا بدهد، نه «جایی بین N و N به‌علاوه‌ی سی».

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

## سقف‌های ثابت پلتفرم {#hard-limits}

مستقل از سیاست شما، چند عدد در خود اسکیمای انبار داده نشسته‌اند:

| چه چیزی | چقدر | مستقل از سیاست حساب |
|---|---|---|
| جدول `events` | ۴۰۰ روز | بله |
| دفتر `engagement` | ۴۰۰ روز | بله |
| ادعاهای رد‌شده در `engagement` (امضای نامعتبر و مانند آن) | ۳۰ روز | بله |
| فایل خروجی | ۷ روز | بله |
| پنجره‌ی ضبط دیباگر رویداد | ۳۰ دقیقه | بله |

پس سیاست نگه‌داری ۳۶۵۰ روز روی رویدادها، ۳۶۵۰ روز رویداد به شما نمی‌دهد. جدول خودش بعد از ۴۰۰ روز سطرها را می‌برد.

در جهت دیگر، این جدول‌ها **هیچ TTL ندارند و جاروی نگه‌داری هم به آن‌ها دست نمی‌زند**: `daily_user_stats`، `segment_members`، `identity_map`، `message_touch`. یعنی سیاست نود‌روزه روی رویدادها، همچنان برای هر کاربر و هر روز یک سطر در `daily_user_stats` باقی می‌گذارد، برای همیشه. آن سطر خود رویداد نیست، شمارش روزانه‌ی همان آدم است، ولی همچنان می‌گوید آن آدم آن روز فعال بوده.

## پیکسل باز شدن و ردیابی کلیک {#tracking}

**کلیک‌های ایمیل از هیچ ریدایرکتی رد نمی‌شوند.** لینک‌های داخل پیام دو پارامتر می‌گیرند، `sg_mid` و `sg_t`، و گیرنده مستقیم روی سایت خود شما می‌نشیند؛ SDKای که همان‌جاست ورودش را گزارش می‌کند. دلیلش این است که دروازه‌ی ایمیل شرکتی هر لینک هر پیام را قبل از رسیدن به گیرنده باز می‌کند تا بدافزار پیدا کند، پس یک ریدایرکت شمارنده، بیشتر یک فهرست سازمانی را «کلیک‌کرده» گزارش می‌کرد.

نتیجه‌اش برای سیاست حریم خصوصی شما: سگمنتیک کلیک را فقط وقتی می‌بیند که سایت یا اپ خودتان رویدادش را بفرستد. اگر صفحه‌ی مقصد SDK ندارد، هیچ داده‌ی کلیکی وجود ندارد. آن رویداد را سیستم شما ثبت می‌کند، با هر چیزی که خود سیستم شما ضمیمه کند.

باز شدن ایمیل فرق دارد، چون صفحه‌ی فرودی ندارد. یک پیکسل لازم دارد و آن پیکسل روی میزبان ورودی است:

```http
GET /e/o?sg_mid=c104.u_9137&sg_t=8mBv2h7oQ1w HTTP/1.1
Host: in.segmentic.net
```

پاسخ همیشه یک GIF شفاف یک‌در‌یک، ۴۳ بایت، است. حتی با توکن جعلی، حتی وقتی پیامی با آن شناسه نداریم، حتی وقتی پایگاه داده‌ی خودمان پایین است. عکس شکسته وسط یک ایمیل بازاریابی، آشکارترین عیبی است که گیرنده می‌بیند و هیچ‌کدام از آن خرابی‌ها تقصیر او نیست.

وقتی امضا معتبر باشد و گیرنده پیدا شود، یک رویداد `message_opened` ثبت می‌شود که این‌ها را همراه دارد:

- شناسه‌ی کاربر گیرنده و شناسه‌ی پیام.
- **نشانی IP کسی که تصویر را گرفته**، و کشور و استان و شهر حاصل از همان IP.
- رشته‌ی User-Agent همان درخواست، که برای تشخیص مرورگر و سیستم‌عامل و ربات پارس می‌شود.
- زمان، از ساعت خودمان و هرگز از هدر. فاصله‌ی بین ارسال و باز شدن یکی از معدود عددهای صادق ایمیل است و مقداری که ادعاکننده کنترلش می‌کند، اندازه‌گیری نیست.

پیکسل با هدر `Cache-Control: no-store` و `Pragma: no-cache` می‌آید، چون گوگل و بقیه‌ی ارائه‌دهنده‌های بزرگ تصاویر بیرونی را پراکسی و کش می‌کنند و بدون این هدرها، پراکسی یک بار می‌گیرد و تا ابد نسخه‌ی خودش را نشان می‌دهد، پس هر بار باز کردن بعد از اولی نامرئی است.

ردیابی باز شدن فقط جایی روشن می‌شود که یک توکن امضا وجود داشته باشد. بدون توکن، پیکسل اصلاً داخل پیام گذاشته نمی‌شود: هر کسی می‌تواند یک نشانی را باز کند، پس شمارش یک ضربه‌ی بدون امضا، همان عددی را باد می‌کند که این کانال با آن سنجیده می‌شود.

لغو اشتراک هم روی همین میزبان است، و نکته‌اش این است که **لغو اشتراک با GET، لغو نمی‌کند**. `GET /e/u` صفحه‌ای با یک دکمه نشان می‌دهد که POST می‌کند. اسکنرهای امنیتی بیشتر بانک‌ها و خرده‌فروش‌های بزرگ ایران هر لینک هر پیام ورودی را قبل از دیدن گیرنده باز می‌کنند؛ GETای که آدم‌ها را لغو اشتراک کند، لحظه‌ی رسیدن کمپین، کل فهرست یک شرکت را بی‌صدا لغو می‌کرد و اولین نشانه‌اش برای مشتری، گزارش دسترسی‌ای بود که فرو ریخته. `POST /e/u` همان مسیر یک‌کلیک RFC 8058 است که خود سرویس‌دهنده‌های ایمیل صدا می‌زنند و هیچ مرحله‌ی تاییدی ندارد. `GET /e/p` صفحه‌ی تنظیمات خود گیرنده است.

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

## آنچه در هر رویداد ذخیره می‌شود {#event-data}

**نشانی IP کامل ذخیره می‌شود.** ستونی به نام `ip` روی جدول رویدادها هست و هیچ ماسک، هیچ کوتاه‌کردن و هیچ هشی روی آن انجام نمی‌شود. اگر نصب طوری تنظیم شده باشد که به هدرهای پراکسی اعتماد کند، سمت چپ‌ترین مقدار `X-Forwarded-For` برداشته می‌شود، وگرنه نشانی خود اتصال.

از همان IP، کشور و استان و شهر استخراج و در ستون‌های جدا ذخیره می‌شود، مگر اینکه SDK خودش مکان فرستاده باشد.

باقی چیزهایی که هر رویداد می‌تواند حمل کند و در انبار داده می‌نشیند: نشانی و مسیر و عنوان و ارجاع‌دهنده‌ی صفحه، پارامترهای `utm_*`، نوع و مدل و سازنده‌ی دستگاه، نام و نسخه‌ی سیستم‌عامل، نام و نسخه‌ی مرورگر، اپراتور شبکه، زبان، منطقه‌ی زمانی، نسخه‌ی اپ، و هر ویژگی‌ای که خودتان روی رویداد گذاشته‌اید.

رشته‌ی خام User-Agent روی جدول رویدادها ذخیره **نمی‌شود**؛ فقط چیزهایی که از آن استخراج می‌شود می‌مانند، به‌علاوه‌ی پرچم ربات.

پرونده‌ها این‌ها را نگه می‌دارند: ایمیل، شماره‌ی همراه، نام و نام خانوادگی، جنسیت، تاریخ تولد، کد ملی، شهر و استان و کشور، زبان، منطقه‌ی زمانی، و نقشه‌ی ویژگی‌های خودتان. خروجی پرونده‌ها عمداً `ip` و `national_id` را حمل نمی‌کند؛ [گزارش و خروجی گرفتن](/docs/reports) را ببینید.

دیباگر رویداد در پنل، جریان زنده‌ی بار خام ورودی را نشان می‌دهد: آدرس ایمیل، محتوای سفارش، و گاهی شماره‌ای که کسی نمی‌خواسته در یک ویژگی بگذارد. به همین دلیل دسترسی `profile.read` می‌خواهد، پاسخش `Cache-Control: no-store` دارد و پنجره‌ی ضبطش ۳۰ دقیقه است.

آنچه ضبط می‌کند فقط فراخوانی‌های تکی است، یعنی `POST /v1/track` و همتاهایش، به‌علاوه‌ی تحویل وب‌هوک. مسیر دسته‌ای اصلاً ضبط نمی‌شود و هر SDK منتشرشده‌ای دسته می‌فرستد، پس نصب SDK در این صفحه چیزی نشان نمی‌دهد. برای اینکه ببینید رویدادهای یک اپ رسیده‌اند یا نه، صفحه‌ی اتصال همان اپ در پنل ابزار درست است.

## بی‌نام‌سازی {#anonymisation}

نقطه‌ی پایانی بی‌نام‌سازی وجود ندارد. هیچ حالتی نیست که سطر بماند و شناسه‌اش خالی شود. آنچه هست، دو چیز است: حذف یک نفر، و انقضا بر اساس سیاست.

تنها ابزار بی‌نام‌سازی در کل پلتفرم، همان HMAC است که سابقه‌ی حذف را نگه می‌دارد و در [چه چیزی می‌ماند](/docs/privacy#what-is-kept) شرحش آمد. کلیدش از کلید مخفی نصب گرفته می‌شود و آن کلید باید دست‌کم ۳۲ نویسه باشد، وگرنه سرویس اصلاً کلید نمی‌سازد و ثبت درخواست حذف ۵۰۳ می‌گیرد.

> [!warn]
> **بازدیدکننده‌ی ناشناسی که هرگز خودش را معرفی نکرده، با هیچ درخواستی قابل حذف نیست.** رویدادهای او `user_id` خالی دارند و تنها راه رسیدن به آن‌ها، `anonymous_id`هایی است که در `identity_map` به یک شناسه‌ی کاربر وصل شده‌اند. اگر آن مرورگر هرگز به یک کاربر متصل نشده، هیچ شناسه‌ای وجود ندارد که درخواست بتواند نامش را ببرد. آن رویدادها با سیاست نگه‌داری و با TTL چهارصدروزه‌ی جدول از بین می‌روند، نه با یک درخواست حذف.

ربات‌ها هم پاک نمی‌شوند، فقط با `is_bot` علامت می‌خورند و از گزارش‌ها بیرون می‌مانند.

## آنچه وجود ندارد {#gaps}

فهرست صادقانه، چون یک جای خالی که بدانید از یک جمله‌ی قابل‌قبول که ندانید ارزش بیشتری دارد:

- هیچ نقطه‌ی پایانی حریم خصوصی روی `api.segmentic.net`. نه ثبت حذف، نه خواندن صف، نه خواندن یا تغییر سیاست نگه‌داری. همه فقط از پنل.
- هیچ خروجی «همه‌ی داده‌ی این یک نفر». تایم‌لاین سقف ۱۰۰ رویداد دارد و خروجی پرونده‌ها فیلتر تک‌نفره ندارد.
- هیچ فراخوان برگشتی یا وب‌هوکی وقتی یک حذف تمام می‌شود. باید صف را دوباره بخوانید.
- هیچ راهی برای حذف بازدیدکننده‌ی ناشناس متصل‌نشده.
- هیچ سیاست نگه‌داری برای یک نفر. سیاست فقط سطح حساب است.
- هیچ حالت بی‌نام‌سازی به‌جای حذف.
- حذف به امتیاز درگیری، امتیاز ریزش، پاسخ‌های نظرسنجی و صف تحویل رله نمی‌رسد. اگر تعهد شما پاک‌شدن کامل است، این چهار مورد امروز دستی‌اند.
- هیچ سطری در فهرست مسدودسازی برای شماره‌ی همراه حذف‌شده نوشته نمی‌شود. فقط ایمیل چنین محافظتی می‌گیرد.
- شمارش‌های داخل گزارش حذف فقط همان جدول‌هایی را می‌شمارند که حذف به آن‌ها دست زده، نه چیزی بیشتر.

برای اینکه یک آدم چطور خودش کانال و موضوع را عوض می‌کند، [رضایت و لغو اشتراک](/docs/consent) را ببینید. برای اینکه چه چیزی اصلاً وارد رویداد می‌شود، [رویدادها](/docs/events).

---

# نسخه‌بندی API و تغییرها

> چه چیزی بدون خبر عوض می‌شود، چه چیزی نمی‌شود، و اینکه هر تغییر API چطور در همان تغییر مستند می‌شود.
> https://segmentic.net/docs/versioning

## نسخه یک بخش از مسیر است {#prefix}

نسخه امروز `v1` است و در مسیر نشانی می‌نشیند. روی هر دو میزبان همین‌طور است: `https://in.segmentic.net/v1/batch` و `https://api.segmentic.net/v1/whoami`.

جای دیگری نسخه نگه‌داری نمی‌شود. هدر نسخه خوانده نمی‌شود، `Accept` سفارشی خوانده نمی‌شود، پارامتر پرس‌وجوی نسخه خوانده نمی‌شود، و بازبینی تاریخ‌محور وجود ندارد. اگر نسخه را جایی جز مسیر بفرستید، بی‌صدا نادیده گرفته می‌شود.

دلیلش ساده است: یک بخش از مسیر در لاگ، در گزارش خطا و در تنظیمات پراکسی دیده می‌شود. هدر دیده نمی‌شود و در مسیر عبور گم می‌شود. وقتی نیمه‌شب دارید یک درخواست شکست‌خورده را دنبال می‌کنید، مسیر تنها چیزی است که همه‌جا هست.

هیچ بخش `/api` در مسیر نیست. مسیر درست `/v1/events` روی میزبان `api.segmentic.net` است، نه `/api/v1/events`. آدرسی که آن بخش اضافه را داشته باشد `404` با کد `unknown_endpoint` می‌گیرد و متن پاسخ، متد و مسیری را که فرستادید می‌گوید.

تا امروز فقط یک نسخه منتشر شده است. `v2` وجود ندارد و تاریخی هم برایش اعلام نشده است.

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

---

## تغییر شکننده چیست {#breaking}

این‌ها شکننده‌اند و بدون عوض‌شدن نسخه انجام نمی‌شوند:

- حذف یک کلید از پاسخ
- عوض‌شدن نوع یک کلید، مثلا از رشته به عدد یا از مقدار ساده به شیء
- حذف یک نقطه پایانی یا تغییر مسیر آن
- اجباری‌شدن فیلدی که تا دیروز اختیاری بود
- تنگ‌ترشدن مقدارهای پذیرفته‌شده یک ورودی
- عوض‌شدن معنای یک کلید، وقتی نام و نوعش سر جایش می‌ماند
- عوض‌شدن مقدار پیش‌فرض یک پارامتر، طوری که خروجی فرق کند
- عوض‌شدن کد وضعیت پاسخ برای حالتی که از قبل وجود داشت

خطرناک‌ترین مورد این فهرست ششمی است. حذف یک کلید پرصدا از کار می‌افتد و شما همان روز می‌فهمید. عوض‌شدن معنای یک کلید بی‌صدا اشتباه می‌شود و ممکن است هفته‌ها بعد در گزارش شما پیدا شود. به همین دلیل آن را هم‌سنگ حذف حساب می‌کنیم.

وقتی می‌خواهیم پاسخ کامل‌تری بدهیم، پاسخ کامل‌تر زیر یک کلید تازه کنار کلید قدیمی می‌نشیند و شکل کلید قدیمی دست نمی‌خورد. فرض کنید `count` یک عدد است و بعدا لازم می‌شود تفکیک آن عدد هم برگردانده شود. کاری که نمی‌کنیم این است که `count` را به یک شیء تبدیل کنیم. کاری که می‌کنیم این است که `count_breakdown` را کنارش اضافه کنیم. `count` همان عدد می‌ماند، با همان معنا.

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

هزینه این قاعده را هم پنهان نمی‌کنیم. پاسخ‌ها با گذشت زمان بزرگ‌تر و شلوغ‌تر می‌شوند و چند کلید در آن‌ها یادگاری گذشته‌اند. این را در برابر شکستن اتصال مشتری پذیرفته‌ایم. نمونه‌اش همین امروز در محصول هست: `GET /v1/schema/traits` هنوز `traits` را به‌صورت آرایه ساده نام‌ها برمی‌گرداند، چون چیزی بیرون آن را به‌عنوان رشته پیمایش می‌کند، و جواب کامل‌تر در کلید دوم `schema` کنارش نشسته است.

---

## تغییر شکننده چه چیزی نیست {#not-breaking}

این‌ها هر زمان، بدون اعلام قبلی و بدون عوض‌شدن نسخه انجام می‌شوند:

- افزودن یک کلید تازه به پاسخ
- افزودن یک فیلد اختیاری تازه به ورودی
- افزودن یک نقطه پایانی تازه
- افزودن یک مقدار تازه به فهرست مقدارهای مجاز یک فیلد
- افزودن یک هدر تازه به پاسخ
- عوض‌شدن ترتیب کلیدها در شیء JSON
- رفع اشکالی که پاسخ را با مستندات هماهنگ می‌کند
- تغییر سرعت پاسخ و جزئیات درونی پیاده‌سازی

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

مثال زنده از همین محصول: وضعیت یک خروجی امروز یکی از `queued`، `running`، `ready`، `failed` یا `expired` است. اگر روزی وضعیت ششمی اضافه شود، این یک تغییر افزایشی است و اعلام قبلی نمی‌گیرد. کدی که روی این پنج مقدار `switch` می‌زند و شاخه پیش‌فرض ندارد، همان روز می‌افتد.

---

## کلاینتی که تغییر معمولی ما را تحمل کند {#tolerant}

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

به‌طور مشخص:

- در Go، `DisallowUnknownFields` را روی پاسخ‌های ما روشن نکنید
- در Java و Jackson، `FAIL_ON_UNKNOWN_PROPERTIES` را خاموش بگذارید
- در هر زبان دیگری، اعتبارسنجی سخت‌گیر شمای پاسخ را روی مسیر اصلی نگذارید

```go title="whoami.go"
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

// Only the three fields this program uses. A key we add next month is
// decoded into nothing and the program carries on. A strict decoder would
// return an error instead, and the caller would read that as "the API is
// down" on the day we shipped a harmless addition.
type Whoami struct {
	TenantID    uint32   `json:"tenant_id"`
	Role        string   `json:"role"`
	Permissions []string `json:"permissions"`
}

func whoami(ctx context.Context, key string) (Whoami, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://api.segmentic.net/v1/whoami", nil)
	if err != nil {
		return Whoami{}, err
	}
	req.Header.Set("Authorization", "Bearer "+key)

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return Whoami{}, err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		return Whoami{}, fmt.Errorf("whoami: http %d", res.StatusCode)
	}

	var out Whoami
	// No DisallowUnknownFields here, deliberately.
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		return Whoami{}, err
	}
	return out, nil
}

func main() {
	me, err := whoami(context.Background(), os.Getenv("SEGMENTIC_API_KEY"))
	if err != nil {
		fmt.Println("could not read whoami:", err)
		os.Exit(1)
	}
	fmt.Println(me.Role, me.Permissions)
}
```

سه عادت دیگر هم اتصال را شکننده می‌کند و از سمت ما قابل جبران نیست:

- تکیه بر ترتیب کلیدها در JSON
- تکیه بر نبودن یک کلید، به‌جای بررسی مقدار آن
- خواندن تاریخ با بریدن رشته، به‌جای تجزیه کامل آن

یک تبصره که سردرگمی می‌سازد اگر گفته نشود: `POST /v1/messages` در **ورودی** فیلد ناشناس را رد می‌کند. این خلاف قاعده بالا نیست. قاعده بالا درباره خواندن **پاسخ** ماست. آن مسیر روی ورودی سخت‌گیر است چون کسی که `idempotency_key` را غلط تایپ کرده، وگرنه در هر تلاش دوباره یک کلید تازه می‌گرفت و به‌ازای هر تلاش یک پیام می‌فرستاد. هیچ مسیر دیگری روی هیچ‌کدام از دو میزبان، فیلد ناشناس ورودی را رد نمی‌کند.

---

## توانمندی‌ها، جواب زمان اجرا {#capabilities}

پرسش «آیا این نصب می‌تواند فلان کار را بکند» یک جواب زمان اجرا دارد، نه یک جواب مستند:

```bash
curl -s https://api.segmentic.net/v1/capabilities \
  -H "Authorization: Bearer sk_seg_..."
```

```json
{
  "version": "v1",
  "features": {
    "segments": true,
    "campaigns": true,
    "analytics": true,
    "transactional": true,
    "export": false,
    "import": true,
    "journeys": true,
    "ingest": true,
    "async_exports": true,
    "campaign_approval": true
  },
  "limits": {
    "max_page_size": 100,
    "max_preview_rows": 100,
    "max_batch_size": 500,
    "estimate_sample": 100,
    "query_timeout_sec": 30
  }
}
```

این تنها ساز و کار سازگاری رو به جلوی ماست. به‌جای اینکه فهرست قابلیت‌ها را در کد خودتان ثابت بنویسید، آن را از همین‌جا بپرسید. قابلیتی که بعدا اضافه شود، بدون عوض‌شدن نسخه در همین پاسخ ظاهر می‌شود و یکپارچه‌سازی شما بدون تغییر کد آن را می‌بیند.

دو نصب سگمنتیک واقعا با هم فرق دارند. بیشتر قابلیت‌ها فقط وقتی مسیرشان ثبت می‌شود که پیکربندی‌شان وجود داشته باشد، پس کلاینتی که کل سطح را فرض کند دارد علیه یک داستان کد می‌نویسد. `features.transactional` که `false` باشد یعنی `POST /v1/messages` روی آن نصب `404` می‌دهد، نه `403`.

سقف‌ها هم منتشر می‌شوند به‌جای اینکه فقط مستند شوند، تا هیچ کلاینت و هیچ عاملی عددی را hardcode نکند که ما بعدا عوضش می‌کنیم. اگر `max_batch_size` روزی از ۵۰۰ بالاتر برود، کلاینتی که آن را از اینجا خوانده خودش بزرگ‌تر batch می‌فرستد.

دو نکته درباره‌اش:

- این پاسخ را در زمان راه‌اندازی بخوانید و مدتی نگه دارید. خواندنش پیش از هر فراخوان، بار بی‌جهت است و یک واحد از [بودجه](/docs/api/management#budget) شما را هم می‌خورد.
- این نقطه پایانی توانمندی را اعلام می‌کند، نه شکل داده را. مذاکره نسخه نیست و شکل پاسخ نقاط پایانی دیگر را عوض نمی‌کند.

و یک صداقت لازم: دو کلید در `features` هیچ مسیری را روی میزبان مدیریتی روشن یا خاموش نمی‌کنند. `export` و `journeys` فقط خبر می‌دهند که آن زیرسامانه پیکربندی شده است؛ دو مسیر خروجی را `async_exports` کنترل می‌کند نه `export`، و هیچ مسیر journey روی این میزبان ثبت نشده که `journeys` بتواند کنترلش کند. `ingest` و `import` هم دقیقا یک بولین‌اند با دو اسم، همانی که `POST /v1/events` را ثبت می‌کند. مقدارهای نمونه بالا مال یک استقرارند نه قول ما: هر پرچم یعنی «این زیرسامانه اینجا پیکربندی شده»، پس روی نصب خودتان بخوانیدش. جدول کامل اینکه هر کلید کدام مسیر را کنترل می‌کند در [مرجع API مدیریتی](/docs/api/management#capabilities-features) است.

---

## چطور از یک تغییر باخبر می‌شوید {#notice}

هر تغییر شکننده دست کم **شش ماه** پیش از اجرایی‌شدن اعلام می‌شود.

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

رفع یک آسیب‌پذیری امنیتی می‌تواند این مهلت را کوتاه کند. در آن صورت علت و دامنه تغییر در همان اعلام نوشته می‌شود.

> [!warn]
> امروز هیچ‌کدام از این دو راه خودکار نیست، و این را صریح می‌نویسیم چون نیمه‌کاره‌بودنش را نگفتن، بدتر از خود نیمه‌کاره‌بودن است. صفحه تغییرات هنوز در این مستندات وجود ندارد. و هیچ فیلدی روی یک حساب، «رابط فنی» را نام نمی‌برد، پس گیرنده آن رایانامه را چیزی در محصول انتخاب نمی‌کند. تا وقتی هر دو ساخته شوند، مطمئن‌ترین راه برای دیدن اینکه چیزی عوض شده، `GET /v1/capabilities` است و همین مستندات، که با خود تغییر جلو می‌آید.

تغییر خود سند سیاست، دست کم ۶۰ روز پیش از اجرایی‌شدن اعلام می‌شود و نسخه‌های پیشینش بایگانی می‌شوند، تا بشود دید در روزی که یک یکپارچه‌سازی نوشته شده، تعهد ما چه بوده است. متن کامل تعهد در [سیاست نسخه‌بندی رابط برنامه‌نویسی](/api-policy) است. اگر جایی از این صفحه با آن سند تعارض داشت، آن سند تعهد است و این صفحه توصیف.

---

## هدر غروب امروز فرستاده نمی‌شود {#headers}

امروز هیچ هدر ماشین‌خوانی برای منسوخ‌سازی فرستاده نمی‌شود. نه `Sunset`، نه `Deprecation`، نه `Warning` روی پاسخ یک نقطه پایانی در حال منسوخ‌شدن.

جایش همان دو چیزی است که بالاتر گفته شد: صفحه تغییرات و رایانامه. غیر از این دو، سیگنال دیگری وجود ندارد.

این را روشن می‌نویسیم چون خلافش گران تمام می‌شود. اگر کسی با این تصور کد بنویسد که سامانه پیش از قطع‌شدن یک هشدار ماشین‌خوان می‌فرستد، آن هشدار هرگز نمی‌آید و پایش او هیچ‌وقت زنگ نمی‌زند. آن روز، اولین نشانه یک `404` است روی مسیری که سال‌ها کار می‌کرد.

اگر روزی این هدرها اضافه شوند، خودش یک تغییر افزایشی است، بدون عوض‌شدن نسخه انجام می‌شود و همین صفحه به‌روز می‌شود.

---

## عمر نسخه قدیمی {#old-version}

بعد از انتشار یک نسخه تازه، نسخه قبلی دست کم **یک سال** زنده می‌ماند و به فراخوان‌ها پاسخ می‌دهد.

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

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

چون تا امروز فقط `v1` منتشر شده، هیچ نسخه‌ای بازنشسته نشده و هیچ مسیری روی هیچ‌کدام از دو میزبان امروز `410` نمی‌دهد. اگر `410` گرفتید، از سگمنتیک نیست؛ از یک پراکسی بین شما و ماست.

---

## مستندات با خود تغییر جلو می‌آید {#docs}

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

نتیجه‌اش این است: یک فیلد به API اضافه می‌شود، تست‌ها سبزند، کار تمام به‌نظر می‌رسد، و در همان لحظه مرجع API آن فیلد را ندارد. هیچ تستی قرمز نمی‌شود و هیچ‌کس بد کار نکرده.

قانون چهارم مخزن سگمنتیک برای همین است: **توصیف محصول در همان تغییری منتشر می‌شود که خود محصول.** این یک ترجیح سلیقه‌ای نیست، شرط پذیرش کار است، هم‌سنگ سه قانون دیگر که درباره امنیت و دوزبانه‌بودن‌اند. متنش می‌گوید توصیفی که با محصول نخواند، از نبودنش بدتر است، چون کسی به آن اعتماد می‌کند و اشتباه می‌افتد.

برای API، سطر مربوطه در جدول آن قانون این است: نقطه پایانی یا فیلد تازه در API، یعنی مرجع API و نمونه کدش در همان تغییر عوض می‌شوند.

### چه چیزی خودکار بررسی می‌شود {#docs-automated}

سه چیز خودکارند. یکی پیش از بیلد اجرا می‌شود و دو تای دیگر در `CI`:

- نگهبان مستندات، که پیش از هر بیلد سایت اجرا می‌شود و بیلد را با کد خروج ۱ می‌خواباند. از این سه، تنها چیزی است که به یک بیلد چسبیده.
- `npm run check:locales` در پوشه پنل، که هر رشته نمایشی را در برابر جفت انگلیسی‌اش می‌گذارد. این سطر قانون دوم است. قدمی در `CI` است و دستوری که خودت هم می‌توانی بزنی؛ هیچ بیلدی در پنل صدایش نمی‌زند.
- `scripts/check-api-is-documented.mjs`، که قدم جداگانه‌ای در `CI` است و قانون هفتم را می‌سنجد. دو جهت دارد که پایین‌تر آمده‌اند.

جهت اول: مسیری که روی کالکتور یا API مدیریتی ثبت شده باشد و در مرجع همان سطح نباشد، بیلد را قرمز می‌کند. هشت مسیر مستثنا هستند و اسکریپت هر کدام را با دلیلش می‌نویسد: پیکسل باز شدن، دو مسیر لغو اشتراک، دو مسیر مرکز ترجیحات، ریدایرکت لینک کوتاه، ورودی برگشتی‌ها، و preflight مربوط به CORS. هیچ‌کدام مسیری نیست که مشتری صدایش بزند.

جهت دوم باریک‌تر از چیزی است که به گوش می‌آید. در هر صفحه‌ای، نمونه کدی که خط درخواستش `in.segmentic.net` یا `api.segmentic.net` را نام ببرد با مسیری که آن میزبان سرو نمی‌کند، بیلد را قرمز می‌کند. خط درخواستی که **بدون** میزبان نوشته شده بررسی نمی‌شود، و این عمدی است: همان شکلی است که صفحه‌های صادق برای نشان‌دادن فراخوانی به کار می‌برند که مشتری نمی‌تواند بزند. صفحهٔ [حریم خصوصی](/docs/privacy) عبارت `POST /v1/privacy/erasures` را زیر جمله‌ای می‌نویسد که می‌گوید با `curl` نمی‌شود، و [پیام تراکنشی](/docs/transactional) مسیرهای قالب را زیر هشداری می‌آورد که می‌گوید میزبان مدیریتی هیچ‌کدامشان را سرو نمی‌کند. بررسی‌ای که این‌ها را بخواباند، به همه یاد می‌دهد به‌جای درست‌کردن چیزی، توضیح را پاک کنند.

نگهبان مستندات این‌ها را می‌گیرد:

- صفحه‌ای که فقط به یک زبان نوشته شده. صفحه‌ای که به فارسی هست و به انگلیسی نیست، کار ناتمام است نه کار تمام که منتظر ترجمه است.
- دو زبانی که لنگرهای یکسان ندارند. تعویض زبان وسط صفحه باید جای شما را حفظ کند، و این فقط وقتی می‌ماند که چیزی بررسی کند.
- em dash و دو خویشاوندش، که قانون اول است.
- نشانه‌های نوشتار ماشینی در فارسی: اعراب، فلش راست در متن راست‌به‌چپ، نقطه وسط، ارقام عربی، ی و ک عربی.
- لینکی که به صفحه یا لنگری اشاره کند که وجود ندارد.
- ارقام لاتین در نثر فارسی، به‌صورت هشدار.

### چه چیزی خودکار بررسی نمی‌شود {#docs-manual}

اینجا اسکریپتی نیست که همه‌اش را بگیرد و وانمود نمی‌کنیم هست.

**هیچ چیزی نام یک فیلد مستندشده را با کد مقایسه نمی‌کند.** اگر فردا فیلدی از یک پاسخ حذف شود و این صفحه هنوز نامش را ببرد، نگهبان سبز می‌ماند و بیلد رد می‌شود. آنچه این را می‌گیرد یک آدم است که پیش از باز کردن Pull Request جدول قانون چهارم را از بالا تا پایین می‌خواند و می‌پرسد کدام سطرش به این تغییر می‌خورد.

پس اگر جایی از این مستندات با رفتار واقعی API مخالف بود، **API درست است**. آن مورد را به پشتیبانی بگویید؛ یک صفحه غلط، دقیقا همان چیزی است که این قانون برای نداشتنش نوشته شده.

---

## امروز چه چیزی درست است {#today}

خلاصه صادقانه وضع امروز، برای کسی که دارد یک اتصال تولیدی می‌نویسد:

| چیز | امروز |
|---|---|
| نسخه منتشرشده | فقط `v1` |
| `v2` | وجود ندارد، تاریخی هم اعلام نشده |
| جای نسخه | بخشی از مسیر، روی هر دو میزبان |
| صفحه تغییرات | هنوز ساخته نشده |
| رایانامه اعلام تغییر | هیچ فیلدی روی حساب رابط فنی را نام نمی‌برد |
| هدر `Sunset` و `Deprecation` | فرستاده نمی‌شود |
| مسیری که `410` بدهد | وجود ندارد، چون نسخه‌ای بازنشسته نشده |
| مذاکره نسخه با هدر | خوانده نمی‌شود |

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

1. `GET /v1/capabilities` در زمان اجرا، برای اینکه بدانید این نصب چه دارد و سقف‌هایش چقدر است.
2. همین مستندات، که طبق قانون چهارم با خود تغییر منتشر می‌شود. توصیف ماشین‌خوان همین سطح در [فایل OpenAPI](/docs/openapi) است.
3. صفحه [سیاست نسخه‌بندی رابط برنامه‌نویسی](/api-policy)، که تعهد نوشته‌شده ماست و نسخه‌های پیشینش بایگانی می‌شود.

و یک چیز که امروز به آن تکیه نکنید: هیچ سیگنال ماشین‌خوانی پیش از یک تغییر شکننده نمی‌آید. اگر پایش می‌سازید، روی کد خطا و روی `GET /v1/status` بسازید، نه روی هدری که نمی‌آید.

---
