وبهوک ورودی و اتصال به سرویسهای دیگر
گرفتن رویداد از سرویسی که SDK ندارد، و اتصالهایی که آمادهاند.
وبهوک ورودی چیست
راهی برای گرفتن رویداد از سرویسی که SDK ما را ندارد و نمیخواهید داشته باشد.
فروشندهای که فقط روی دیجیکالا میفروشد سایتی ندارد که SDK رویش بنشیند. فروشگاهی که ووکامرس دارد، دارد؛ ولی راهاندازی یک افزونه و انتشار دوبارهی سایت، هفتهها بعد از تصمیم اتفاق میافتد. وبهوک این فاصله را حذف میکند: یک نشانی را در پنل آن سرویس paste میکنید و سفارشها شروع به آمدن میکنند. SDK بعدا ارتقا است و نه پیشنیاز.
اینجا هیچ کلید نوشتنی در کار نیست. حساب شما از خود نشانی بهعلاوهی امضا مشخص میشود، چون در فرم مدیریت آن پلتفرم جایی برای گذاشتن یک هدر وجود ندارد.
آدرس و توکن
POST https://in.segmentic.net/v1/hooks/{source}/{token}
{source} یکی از هفت منبعی است که در منبعها آمده و {token} رشتهای است که هنگام ساختن اتصال، سرور آن را میسازد.
سقف بدنه یک مگابایت است، دقیقا 1048576 بایت. سفارش شاپیفای با دویست قلم کالا حدود دویست کیلوبایت است، پس این سقف سخاوتمندانه است و در عین حال آنقدر کوچک هست که یک POST بدخواه نتواند ما را وادار به بافر کردن یک گیگابایت کند.
بایتهای خام نگه داشته میشوند و امضا قبل از هر تجزیهای روی همانها بررسی میشود. رایجترین باگ وبهوک در دنیا همین است: decode و encode دوباره، ترتیب کلیدها و فاصلهها و قالب اعداد را عوض میکند و امضا روی محتوایی که کاملا اصیل بوده شکست میخورد.
این مسیر فقط وقتی ثبت میشود که نصب شما اتصالها را فعال داشته باشد.
ساختن اتصال در پنل
سه مسیر مدیریتی، هر سه روی کنترلپلین، یعنی همان APIای که پنل با آن حرف میزند. روی میزبان مدیریتی هیچ مسیری برای مدیریت اتصال وجود ندارد.
کنترلپلین از اینترنت مسیردهی نشده است. در استقرار مرجع، 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 |
PUT /api/proxy/v1/integrations
Content-Type: application/json
{"source":"woocommerce","label":"فروشگاه اصلی","secret":"a-shared-secret"}
{
"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 بهروز میشوند.
خاموش کردن یک اتصال بدون حذف آن:
POST /api/proxy/v1/integrations/woocommerce/enabled
Content-Type: application/json
{"enabled":false}
اتصال خاموش به هر تحویلی 404 میدهد، دقیقا همان پاسخی که یک توکن ناشناس میگیرد.
بررسی امضا
طرح یکسان است: 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 نگاه کنید | ندارد |
digikala، basalam، torob، zarinpal | هیچ هدری خوانده نمیشود، بخش بعد | ندارد |
اگر secret ذخیره نشده باشد، هر تحویل 401 میگیرد. تنها استثنا Segment است.
سازوکار بازگشتی ووکامرس برای افزونههای قدیمی است: اگر X-WC-Webhook-Topic خالی باشد، موضوع از X-WC-Webhook-Resource بهعلاوهی نقطه بهعلاوهی X-WC-Webhook-Event (یا updated اگر آن هم نباشد) ساخته میشود. این چیزی است که فروشگاهی با افزونهی دوساله واقعا میفرستد.
چهار منبع ایرانی که از راه HTTP امضایشان تأیید نمیشود
دیجیکالا، باسلام، ترب و زرینپال امروز از راه POST /v1/hooks/{source}/{token} کار نمیکنند. این نقص شناختهشده است و اینجا نوشته شده تا کسی نصف روز دنبال تنظیم اشتباهی نگردد که وجود ندارد.
بدنهی موضوع: تابعی که تصمیم میگیرد امضا را از کدام هدر بخواند، برای شاپیفای و ووکامرس هدر درست را برمیگرداند و برای هر منبع دیگری رشتهی خالی. هیچ شاخهای برای این چهار منبع نوشته نشده. بررسیکنندهی امضا آن رشتهی خالی را میگیرد، یک MAC واقعی base64 را با آن مقایسه میکند، هیچوقت برابر نمیشود، و نتیجه این است:
{"status":"error","message":"signature mismatch"}
با کد 401. همزمان last_error اتصال روی bad signature مینشیند و شمارندهی rejected بالا میرود. اگر هم رمزی تنظیم نکرده باشید، دقیقا همان 401 و همان بدنه و همان bad signature را میگیرید: هندلر برای هر شکست بررسی امضا، دلیلش هر چه باشد، همین دو مقدار ثابت را مینویسد. دلیل no shared secret configured فقط به لاگ سرور میرود، پس هرچقدر هم آخرین خطای پنل را بخوانید این دو حالت از هم جدا نمیشوند.
توجه کنید که خود کتابخانهی تبدیل، این چهار منبع را پشتیبانی میکند: تابع بررسی امضا برایشان همان HMAC-SHA256 روی بدنهی خام را میپذیرد، و تبدیل هر چهار منبع نوشته شده و تست دارد. آنچه وجود ندارد، هدر خواندنی در سمت HTTP است. تا وقتی این اضافه نشده، اگر یکی از این چهار منبع را لازم دارید، با ما تماس بگیرید.
پاسخها
| حالت | کد | بدنه |
|---|---|---|
| منبع ناشناس، توکن ناشناس، یا اتصال خاموش | 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 که اصلا شمرده نمیشوند. روی این مسیر سقف سهمیه بررسی نمیشود.
جلوگیری از تکرار
هر تبدیل یک 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 حالت عادی است و نه بیقاعدگی.
منبعها
هفت منبع، به همان ترتیبی که در پنل دیده میشوند. چهار پلتفرم ایرانی عمدا اول فهرستاند: انتخابگری که با شاپیفای شروع شود، که یک فروشندهی ایرانی اصلا نمیتواند از آن استفاده کند، مثل محصولی خوانده میشود که برای جای دیگری ساخته شده است.
دیجیکالا
وبهوک سفارش پنل فروشنده. فروشگاهی که فقط روی دیجیکالا میفروشد سایتی ندارد که SDK رویش بنشیند، پس این تنها مسیرش به دادهی سفارش است.
موضوعهای پذیرفتهشده: order.created، order.confirmed و موضوع خالی به order_completed؛ order.cancelled و order.canceled به order_cancelled؛ order.returned و order.refunded به order_refunded. هر چیز دیگری رد میشود و پاسخ 200 است.
{
"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 از نخستین قلم.
باسلام
بازارگاه کالای دستساز و محلی. موضوعها: order.paid، order.created و موضوع خالی به order_completed؛ order.cancelled و order.canceled به order_cancelled.
{
"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.
ترب
موتور مقایسهی قیمت. کلیک میفرستد و نه سفارش، و همین آن را برای فروشگاهی که ترافیکش از مقایسه میآید، بالای قیف میکند.
موضوعها: خالی، click یا referral.
{
"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.
زرینپال
بازگشت درگاه پرداخت. حتی برای فروشگاهی که خودش order_completed میفرستد هم ارزش دارد: درگاه تنها طرفی است که میداند پرداخت واقعا تسویه شده، و «سفارش ثبت شد» با «پول رسید» به اندازهی نرخ رهاکردن سبد فرق دارد، که در تجارت الکترونیک ایران کوچک نیست.
موضوعها: خالی، payment یا verify.
{
"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 هستند. زمان ناخوانا به «الان» تبدیل میشود و رویداد رد نمیشود: رویدادی با زمان کمی غلط از رویدادی که اصلا نیامده خیلی باارزشتر است.
ووکامرس
فقط دو موضوع خوانده میشود: 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 است.
شاپیفای
| موضوع | رویداد |
|---|---|
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 امضا نمیکند. بهجای آن، هدر 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 بهعنوان کلید تشخیص تکرار نگه داشته میشود، پس بازپخشی که از هر دو مسیر به ما برسد یک رویداد است و نه دو تا.
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" }
}'
{"status":"ok","accepted":1}
رلهی رویداد: داده به بیرون
همتای بیرونی وبهوک ورودی. رله رویدادها را همانطور که میرسند به نشانی شما POST میکند، تا CRM یا سیستم انبار شما لازم نباشد هر سی ثانیه API ما را poll کند.
این پنج مسیر هم روی کنترلپلیناند، کنار مسیرهای اتصال که بالاتر آمد، و روی میزبان مدیریتی هیچ مسیری برای رله وجود ندارد.
کنترلپلین از اینترنت مسیردهی نشده است. همین پنج مسیر روی 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 |
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"
}
{"id":4}
فهرست events خالی یعنی همهی رویدادها. پیشفرض عمدا پرسروصداست، چون حالت دیگر، یعنی خالی به معنای هیچ، رلهای میسازد که به نظر تنظیمشده میآید و بیصدا هیچ کاری نمیکند، و کشف کردنش یک تیکت پشتیبانی خرج دارد.
فیلترها شرط روی ویژگیهای رویدادند و شکلشان {"prop","op","value"} است. عملگرها: eq، ne، contains، prefix، gt، gte، lt، lte، exists، missing. حداکثر هشت فیلتر، چون بیشتر از آن دیگر تعریف یک سگمنت است و رله شیر آب با یک فیلتر است و نه موتور پرسوجو. همهی فیلترها باید بخورند و نه یکی از آنها. مقایسه با ویژگیای که رویداد اصلا ندارد، نادرست است و خطا نیست. عددی که به شکل متن آمده باشد باز هم بهعنوان عدد مقایسه میشود.
خطاهای اعتبارسنجی 400 میگیرند: نام خالی، نشانی خالی، نام رویداد خالی، عملگر ناشناس، فیلترهای زیادی. اگر نصب کلید مهرومومکردن نداشته باشد، پاسخ 503 است. خط ممیزی مقصد را ثبت میکند و هرگز رمز را.
بدنهای که به نقطهی پایانی شما میرسد عمدا کوچک و پایدار است و نه ساختار داخلی ما. سیستم گیرنده کد کسی دیگر است که ما نمیتوانیم دوباره deploy کنیم، پس هر فیلد اینجا یک قول است:
{
"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 وقتی خالی باشند نمیآیند. دو نگاشت ویژگی که ما داخل خودمان جدا نگه میداریم، اینجا دوباره یکی میشوند؛ دادن دو نگاشت به مشتری برای سرهم کردن، یعنی نشت کردن چیدمان انبار ما داخل کد او.
پخش کردن بعد از بادوام شدن رویدادها انجام میشود و هرگز قبلش. رلهای که برای رویدادی آتش کند که بعد نوشتنش شکست بخورد، به سیستم انبار مشتری از سفارشی خبر میدهد که این پلتفرم هیچ ردی از آن ندارد.
امضای رله
اگر رمزی ذخیره کرده باشید، هر درخواست این دو هدر را میگیرد:
X-Segmentic-Timestamp: 1786032120
X-Segmentic-Signature: 4f1c9a2b...
امضا برابر hex(HMAC-SHA256(secret, timestamp + "." + payload)) است. زمان داخل همان چیزی است که امضا میشود، تا بدنهای که کسی ضبط کرده یک هفته بعد دوباره روی نقطهی پایانی شما پخش نشود و باز هم درست تأیید شود.
هدرهای دیگر: Content-Type: application/json و User-Agent: Segmentic-Relay/1.
تلاش دوباره
مهلت هر تحویل پانزده ثانیه است. حداکثر ده تلاش، با فاصلهی نمایی از ده ثانیه که روی یک ساعت سقف میخورد؛ مجموع کمی کمتر از دو ساعت است. سقف از خود منحنی مهمتر است: بدون آن، دهمین تلاش دو روز بعد میافتاد و تحویلی که دو روز دیر میرسد از تحویلی که هرگز نمیرسد بدتر است، چون سیستم مشتری تا آن موقع بدون آن حسابش را بسته است.
- کد
2xxموفقیت است. - کدهای
408و429و هر5xxدوباره تلاش میشوند: نقطهی پایانی بالاست و حالش بد است. - هر
4xxدیگری دائمی است. ده بار تکرار کردن یک401اعتبارنامه را معتبر نمیکند و فقط لاگ خطای مشتری را ده برابر شلوغتر میکند. - نشانیای که parse نشود، و نشانیای که نگهبان SSRF ردش کند، هم دائمیاند. نگهبان قبل از درخواست و دوباره در هر پرش redirect بررسی میکند، چون میزبان عمومیای که به
127.0.0.1ریدایرکت میدهد، بررسیای را که فقط روی نخستین نشانی انجام شده دور میزند.
۵۰۰ بایت نخست بدنهی یک شکست برای صفحهی گزارش تحویل نگه داشته میشود، چون نقطهی پایانیای که 400 میدهد معمولا در بدنه میگوید چرا، و همان یک جمله کل ارزش آن صفحه است.
POST /v1/relays/{id}/retry هر تحویل مرده را دوباره در صف میگذارد و {"requeued": N} برمیگرداند. نقطهی پایانیای که یک ساعت بد تنظیم شده بود، پشتهای از تحویل مرده میگذارد و راه دیگر این است که از مشتری بخواهیم رویدادها را از سمت خودش بازپخش کند، که نمیتواند، چون رویدادها مال ما بودند.
لینک کوتاه
فقط برای یک کانال و یک دلیل: پیامک به ازای هر بخش صورتحساب میشود، هر بخش فارسی هفتاد نویسه است، و نشانی یک کمپین مرتب شصت نویسه از آن را میگیرد. لینک اغلب گرانترین چیز داخل پیام است و یک جملهی یک کلمه بلندتر، صورتحساب کل مخاطب را دو برابر میکند.
مسیر GET /s/{code} است. پیشوند عمدا یک نویسه است: در هر پیامکی که کوتاهکننده بازنویسی میکند حاضر است و هر نویسه در پیشوند، یک نویسه کمتر برای خود پیام.
code هفت نویسه از الفبای base32 سبک Crockford است: 0123456789ABCDEFGHJKMNPQRSTVWXYZ. حرفهای I و L و O و U عمدا در آن نیستند. یک لینک کوتاه با صدای بلند خوانده میشود، از روی اسکرینشات تایپ میشود و پشت تلفن دیکته میشود، و هر کدام از آن چهار حرف یک تیکت پشتیبانی است که با «میگوید صفحه پیدا نشد» شروع میشود.
کد مشتق میشود و صادر نمیشود:
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} آنجا سرو میشود. تا وقتی تنظیم نشده، کوتاهکننده خاموش است و پیامها دقیقا مثل امروز میروند.
آنچه کلیک ثبت میکند
ریدایرکت 302 است و هرگز 301 نیست. ریدایرکت دائمی را خود گوشی کش میکند و هر بار بعد از اولی هیچوقت به ما نمیرسد، که شمارش کلیک را بدون اینکه چیزی بگوید، به شمارش «نخستین بار» تبدیل میکند.
ترتیب کار: یک خواندن روی کلید اصلی، بعد ریدایرکت، و کلیک بعد از نوشتن پاسخ در یک goroutine روی زمینهای جدا از درخواست با مهلت پنج ثانیه شمرده میشود. زمینهی خود درخواست همان لحظهای که پاسخ نوشته میشود لغو میشود و بهروزرسانی تقریبا همیشه برمیگشت. شکست شمردن فقط در سطح warn لاگ میشود.
کد ناشناس، و همچنین خطای خود جستوجو، 404 ساده میگیرند و نه صفحهی خطا. کد ناشناس تقریبا همیشه کدی است که اشتباه تایپ شده و چیز مفیدی برای گفتن به کسی که هفت نویسه را از روی اسکرینشات خوانده وجود ندارد.
آنچه ثبت میشود دقیقا این است و بیشتر از این نیست:
UPDATE short_links SET clicks = clicks + 1, last_click = $2 WHERE code = $1
یعنی یک شمارنده و یک زمان آخرین کلیک، روی ردیف خود لینک. نه شناسهی فرد، نه IP، نه user agent.
اینجا هیچ توکنی نیست و نمیتواند باشد. هر نقطهی پایانی رهگیری دیگری با sg_mid و یک امضا صدا زده میشود و بدون آنها رد میشود، چون نرمافزاری صدایش میزند که خودمان لینک را به آن دادهایم. این یکی را گوشیای صدا میزند که چیزی جز آن هفت نویسهی چاپشده در پیام ندارد.
پس مدل انتساب اینجا به ازای لینک و به ازای کمپین است و هرگز به ازای فرد نیست. شصت هزار گیرندهی یک کمپین همان یک کد را دارند. برای پوش و ایمیل مدل به ازای فرد سر جایش هست و بهتر است: پارامترهای sg_mid و sg_t روی لینک نوشته میشوند، فرد روی سایت خود مشتری فرود میآید و SDKای که از قبل آنجاست ورودش را گزارش میکند. برای ایمیل هیچ نقطهی پایانی ریدایرکتی وجود ندارد، چون یک پرش اضافهی ما میتوانست شکست بخورد، دامنهای میداد که بشود مسدودش کرد، و بدتر از همه هر کلیک اسکنر ضدبدافزار شرکتی را میشمرد که روی یک فهرست کسبوکاری میتواند بیشتر کلیکها باشد.
هیچ نقطهی پایانی و هیچ صفحهای این شمارنده را به شما نشان نمیدهد. تابع خواندن کلیکهای یک کمپین در لایهی انبار نوشته شده و در کل درخت کد هیچ فراخوانی ندارد؛ نه مسیری در API آن را میخواند و نه صفحهای در پنل نشانش میدهد. کلیکها در ستونی شمرده میشوند که امروز قابل خواندن نیست.
چه چیزی امروز نیست
اینجا فقط چیزهایی میآیند که کد شما را عوض میکنند: کاری که دنبالش میگردید و پیدا نمیکنید، یا کاری که باید جور دیگری انجامش بدهید. تصمیمهایی که عمدیاند، مثل نبودن ریدایرکت کلیک ایمیل یا برنگشتن رمز ذخیرهشده، همانجایی توضیح داده شدهاند که تصمیم گرفته شدهاند.
- تأیید امضای دیجیکالا، باسلام، ترب و زرینپال از راه HTTP. بخش چهار منبع ایرانی.
- خواندن کلیک لینک کوتاه. شمارنده وجود دارد و هیچ راهی برای دیدنش نیست.
- چرخاندن توکن وبهوک. ذخیرهی دوباره توکن را عوض نمیکند و مسیر دیگری هم برای عوض کردنش نیست. اگر توکنی لو رفت، اتصال را خاموش کنید و با ما تماس بگیرید.
- مسیرهای مدیریتی اتصال و رله روی میزبان مدیریتی. این مسیرها فقط روی کنترلپلین داشبورد ثبت شدهاند و آن شنونده از اینترنت مسیردهی نشده است، پس کلید
sk_seg_به هیچکدامشان نمیرسد. راه ورود، پروکسی خود پنل است.