- Fix tag filtering to correctly match Persian tags by adjusting JSON encoding in the applyTagFilter method. - Add new endpoint GET /api/v1/blogs/tags to retrieve distinct tag names and their counts for published posts, respecting city scope. - Update API documentation to reflect changes in tag filtering and the new tags endpoint. - Create BlogTagFilterTest to ensure correct functionality of tag filtering and facets, including edge cases for Persian tags and city filtering.
26 KiB
Blog API
Prefix:
/api/v1/blog,/api/v1/blogs
GET /api/v1/blogs
List published blog posts.
Permission: PUBLIC
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
integer | ❌ | 1 | Page number |
limit |
integer | ❌ | 20 | Items per page |
tag |
string | ❌ | — | Filter by exact tag name (e.g. ?tag=چشم و گوش). Blog tags are stored as a JSON array of names; only posts whose tags array contains this exact name are returned. The match is exact, not a prefix and not a wildcard — ?tag=سلامت does not match سلامت عمومی, and _ / % are literal characters. The vocabulary of valid values is GET /api/v1/blogs/tags. |
city_id |
integer | ❌ | — | Scope to one city. Returns that city's posts plus every nationwide post (city_id IS NULL). Omit it to return all published posts regardless of city. |
Response 200
Real output of GET /api/v1/blogs?limit=1&tag=چشم و گوش:
{
"success": true,
"data": [
{
"uuid": "b0b23efa-c974-4dc4-a1a2-ea78d99b75af",
"title": "تشخیص و نشانههای خشکی چشم مزمن با سوزش و خارش؛ ضرورت انجام سنجش تخصصی بیناییسنجی",
"slug": "تشخیص-و-نشانههای-خشکی-چشم-مزمن-با-سوزش-و-خارش؛-ضرورت-انجام-سنجش-تخصصی-بیناییسنجی-b0b23efa",
"summary": "خشکی چشم همراه با سوزش و خارش مزمن در یزد به دلیل شرایط محیطی اهمیت یافته است و تشخیص به موقع با سنجش تخصصی بیناییسنجی ضروری است.",
"image_url": null,
"tags": ["چشم و گوش"],
"status": "published",
"review_status": "approved",
"reviewer": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "ادمین" },
"topic_slug": "optometry-symptoms-132",
"scheduled_at": null,
"representation": null,
"city": { "id": "132", "name": "یزد" },
"created_at": 1785236601
}
],
"meta": {
"totalRecords": 16,
"totalPages": 16,
"currentPage": 1,
"limit": 1
}
}
tagsآرایهٔ رشته است (نه آبجکت{id,name}) و تصویر درimage_urlمیآید — نسخهٔ قبلی این نمونه شکل دیگری نشان میداد که با کد همخوان نبود.
چرا فیلتر tag قبلاً همیشه خالی برمیگشت
ستون blogs.tags از نوع Doctrine json است و JsonType آن را با json_encode بدون JSON_UNESCAPED_UNICODE مینویسد؛ یعنی نام فارسی به شکل ["چشم و گوش"] ذخیره میشود. JSON_CONTAINS در MariaDB این escapeها را نرمال نمیکند و مقایسه روی همان بایتها انجام میشود، پس کاندیدِ فارسیِ خام هرگز match نمیشد. کاندید حالا با همان json_encode پیشفرض ساخته میشود. رگرسیونگارد: tests/Blog/BlogTagFilterTest.php.
فیلد city — شهر پست
city در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
| مقدار | معنا |
|---|---|
{ "id": "123", "name": "یاسوج" } |
پست شهری — به آن شهر تعلق دارد |
null |
پست سراسری — حالت دائمی و معتبر، نه «تنظیمنشده» |
city_idرویblogsnullable است وNULLمعنای دائمی «سراسری» دارد. رکوردهای قبل از این تغییر همگی سراسری شدند.- حذف شهر پست را حذف نمیکند (
ON DELETE SET NULL) — پست سراسری میشود. - پست سراسری روی همهٔ دامنههای شهری در لیست دیده میشود؛ فقط canonical آن روی دامنهٔ اصلی مینشیند. به همین دلیل
city_id=Xهم پستهای شهر X و هم پستهای سراسری را برمیگرداند — نه فقط شهر X.
🔗 مصرفکننده: سایت عمومی چند-دامنهای (
nobat724_front) با همین فیلد تصمیم میگیرد پست را روی دامنهٔ شهر canonical کند یا روی دامنهٔ اصلی، و در کدام sitemap بگذارد. تغییر معنایnullقرارداد آن را میشکند.
GET /api/v1/blogs/tags
واژگان تگهای مقالات منتشرشده بههمراه تعداد پست هر تگ. مصرفکننده: ردیف چیپهای دستهبندی در /blogs سایت عمومی.
Permission: PUBLIC
این endpoint با
GET /api/v1/tagsیکی نیست. آن، واژگانِTagentity است (دیابت،تغذیه، …) و به مقالهها وصل نیست؛blogs.tagsآرایهای از رشتههای آزاد است. برای فیلتر مقالات فقط مقادیر همین endpoint معتبرند.
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
city_id |
integer | ❌ | — | همان scope GET /api/v1/blogs: پستهای آن شهر + پستهای سراسری (city_id IS NULL). بدون آن، همهٔ پستهای منتشرشده شمرده میشوند. |
Response 200
مرتبسازی: count نزولی، سپس نام صعودی. تگ خالی و پست بدون تگ اصلاً وارد خروجی نمیشوند، پس هیچ ورودیای count: 0 ندارد.
خروجی واقعی GET /api/v1/blogs/tags?city_id=132:
{
"success": true,
"data": [
{ "name": "سلامت عمومی", "count": 4 },
{ "name": "سلامت روان", "count": 1 },
{ "name": "چشم و گوش", "count": 1 },
{ "name": "گوارش و کبد", "count": 1 }
]
}
countهر تگ دقیقاً برابرmeta.totalRecordsدرGET /api/v1/blogs?tag=<name>با همانcity_idاست؛ یعنی هیچ چیپی به صفحهٔ خالی نمیرسد (تست:testFacetCountMatchesFilteredTotal).city_idناشناخته خطا نمیدهد — دقیقاً مثلGET /api/v1/blogs?city_id=…فقط پستهای سراسری میمانند.- تنها status ممکن
200است؛ ورودی نامعتبرcity_idباmax(1, (int) …)نرمال میشود.
GET /api/v1/admin/blogs
List blog posts of all statuses for the admin panel. The public GET /api/v1/blogs only returns published posts, so the admin panel must use this endpoint to see drafts and archived posts.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
integer | ❌ | 1 | Page number |
limit |
integer | ❌ | 20 (max 50) | Items per page |
status |
string | ❌ | — | Filter by draft / published / archived. Omit for all. |
search |
string | ❌ | — | Title search (LIKE). |
Response 200
Paginated ({ data:[...], meta:{...} }), each item the blog list shape (includes status, review_status, scheduled_at, representation, city).
GET /api/v1/blog/{slug}
Get a single blog post by slug.
Permission: PUBLIC
Path Parameters
| Param | Type | Description |
|---|---|---|
slug |
string | URL slug (e.g. ashnayi-ba-bimari-diabat) or UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
city_id |
int | ❌ | Domain scope of the caller. Same rule as GET /api/v1/blogs?city_id=…: the post is returned only when it belongs to this city or is nationwide (city IS NULL). A post owned by another city — including a post assigned to the root record نوبت 724 (id 600) — returns 404. Omit on the main domain (nobat724.com) to read any post. |
بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ میگرفت و یک محتوا روی چند دامنه تکرار میشد. سایت عمومی (
nobat724_front) روی دامنههای شهری همیشهcity_idرا میفرستد.
Response 200
⚠️ پاسخ double-nested است (
data.data) — کنترلر$this->success(['data' => $blog->toArray()])برمیگرداند. کلاینت بایدdata?.dataرا بخواند.
شکل کامل، عیناً خروجی Blog::toArray():
{
"success": true,
"data": {
"data": {
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image_url": "/uploads/blogs/blog_xxx_cover.png",
"tags": ["دیابت", "تغذیه"],
"sources": [{ "url": "https://...", "title": "..." }],
"status": "published",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [{ "q": "...", "a": "..." }],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "...", "name": "احمدی" },
"city": null,
"created_at": 1717000000,
"updated_at": 1717100000
}
}
}
نکته:
tagsآرایهٔ رشته است (نه آبجکت{id,name})، تصویر درimage_url(نهimage) و نام نویسنده درauthor.name(نهreal_name). نسخهٔ قبلی این سند شکل دیگری نشان میداد که با کد همخوان نبود.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Blog not found, not published, or owned by another city (when city_id is sent) |
GET /api/v1/admin/blog/{uuid}
Get one blog post of any status for the admin edit form.
Permission: ROLE_ADMIN
اندپوینت عمومی GET /api/v1/blog/{slug} هر پستِ غیر published را ۴۰۴ میکند، پس فرم ویرایش پنل ادمین نمیتوانست پیشنویس و آرشیو را بارگذاری کند (فرم خالی بالا میآمد). این اندپوینت هیچ فیلتر status/city ندارد. اندپوینت عمومی عمداً دستنخورده ماند تا پاسخِ cache-شدهٔ آن وابسته به نقشِ فراخوان نشود.
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string | UUID مقاله. الگوی اجباری [0-9a-fA-F-]{36} — بدون آن این route مسیر /api/v1/admin/blog/review-queue را میدزدید. فقط UUID؛ slug پذیرفته نمیشود. |
Response 200 (خروجی واقعی curl روی پیشنویس)
{
"success": true,
"data": {
"data": {
"uuid": "56fd9a20-9594-4aa1-a651-346fa86720bd",
"title": "rtertert",
"slug": "نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20",
"summary": "ertet",
"body": "<p>etetetet</p>",
"image_url": null,
"tags": [],
"sources": [],
"status": "draft",
"review_status": null,
"reviewer": null,
"reviewed_at": null,
"review_note": null,
"topic_slug": null,
"meta_title": null,
"meta_description": null,
"primary_keyword": null,
"secondary_keywords": [],
"faq": [
{ "q": "etrtert", "a": "retet" },
{ "q": "eertret", "a": "ertet" }
],
"internal_links": [],
"external_links": [],
"reading_time": null,
"canonical_url": null,
"og_image": null,
"scheduled_at": null,
"representation": null,
"author": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "1402/12/17 12" },
"city": null,
"created_at": 1784827054,
"updated_at": 1785157798
}
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | بدون توکن |
ERR_FORBIDDEN_001 |
403 | توکن معتبر ولی بدون ROLE_ADMIN |
ERR_NOT_FOUND_001 |
404 | مقاله با این UUID وجود ندارد |
POST /api/v1/blog
Create a new blog post.
Permission: ROLE_ADMIN, or ROLE_IMPORTER (the content pipeline's system user).
A caller that is not
ROLE_ADMINalways produces a review-gated draft: whateverstatus/review_statusit sends, the post is stored asstatus=draft,review_status=pending_review. Approving/publishing staysROLE_ADMIN-only viaPOST /api/v1/admin/blog/{uuid}/review.
Request Body (application/json)
{
"title": "آشنایی با بیماری دیابت",
"body": "<p>محتوای کامل مقاله...</p>",
"summary": "خلاصه کوتاه از مقاله",
"tags": [1, 2],
"status": "draft",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | ✅ | Post title (slug auto-generated) |
body |
string | ✅ | Full HTML body (from the admin CKEditor) |
summary |
string | ❌ | Short excerpt |
tags |
integer[] | ❌ | Array of tag IDs |
status |
string | ❌ | "draft" (default) or "published" |
image_url |
string | ❌ | Cover image path returned by the upload endpoint |
city_id |
integer|null | ❌ | City this post belongs to. Omitting it, or sending null/0, creates a nationwide post. An unknown city id is rejected with 422. |
sources |
array | ❌ | E-E-A-T source list. Each item { "url": "...", "title": "..." }. Used by the content pipeline; shown on the published post. |
review_status |
string|null | ❌ | Medical-review gate. Omit/null = manual admin post (no review). The content pipeline sends "pending_review" so the post enters the doctor review queue (GET /api/v1/admin/blog/review-queue). |
topic_slug |
string | ❌ | Stable pipeline topic key (specialty, angle), unique. Makes creation idempotent: posting the same topic_slug again returns the existing post with HTTP 200 (not a duplicate 201). |
meta_title |
string | ❌ | SEO <title> override (falls back to title). |
meta_description |
string | ❌ | SEO meta description. |
primary_keyword |
string | ❌ | Primary target keyword. |
secondary_keywords |
string[] | ❌ | Secondary keywords (empty entries dropped). |
faq |
array | ❌ | [{ "q": "...", "a": "..." }] — rendered as FAQPage JSON-LD on the site. Malformed entries dropped. |
internal_links / external_links |
array | ❌ | Link suggestions, e.g. ["https://..."] or [{ "url", "anchor" }]. |
reading_time |
integer | ❌ | Estimated minutes. |
canonical_url |
string | ❌ | Canonical override. |
og_image |
string | ❌ | Open Graph image URL. |
scheduled_at |
integer | ❌ | Unix timestamp for automatic publish. Omit for manual publishing. A scheduled post publishes only once review_status = approved (or null) — the review gate wins. |
The same SEO/scheduling fields are accepted on
PATCH /api/v1/blog/{uuid}(omitted keys untouched). All are returned in the blogtoArray.
Response 201
{
"success": true,
"data": {
"uuid": "blog-uuid-...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "...",
"body": "...",
"image": null,
"tags": [],
"status": "draft",
"created_at": 1717000000
}
}
Blog Status Values:
| Value | Description |
|---|---|
draft |
Not visible to public |
published |
Visible in public listing |
archived |
Hidden from listing |
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_VALIDATION_002 |
422 | Missing required fields |
PATCH /api/v1/blog/{uuid}
Update a blog post.
Permission: ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Blog UUID |
Request Body (application/json)
{
"title": "عنوان جدید",
"body": "<p>محتوای جدید</p>",
"summary": "خلاصه جدید",
"tags": [1, 3],
"status": "published",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
All fields optional. Send image_url: "" to clear the cover image.
city_id follows PATCH semantics: omit it and the post's city is left untouched; send null (or 0) to turn the post into a nationwide one; send a city id to move it to that city.
Response 200
Updated blog object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
ERR_VALIDATION_002 |
422 | Unknown city_id |
Representative blog (scoped to one representative)
A representative (ROLE_REPRESENTATION) manages their own posts for their own domain. They see and edit only their own posts, and may target only cities within their coverage (representation.cities). Admin CRUD (/api/v1/blog*) is unaffected — an admin still sees everything.
| Route | Method | Path | Permission |
|---|---|---|---|
| list own | GET | /api/v1/representation/blogs |
ROLE_REPRESENTATION (page,limit,status) |
| get own | GET | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
| create | POST | /api/v1/representation/blog |
ROLE_REPRESENTATION |
| update own | PATCH | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
| delete own | DELETE | /api/v1/representation/blog/{uuid} |
ROLE_REPRESENTATION |
- On create,
author= the representative's user andrepresentation= their record (set server-side). city_idmust be one of the representative's coverage cities, else422. A post that isn't owned by the caller returns404(never leaked).- The request body accepts the same core + SEO + scheduling fields as the admin create.
- The blog
toArray/toListArrayincludesrepresentation({ uuid, name, domain }ornull).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_006 |
403 | Not a representative / no representative profile |
ERR_NOT_FOUND_001 |
404 | Post not found or not owned by the caller |
ERR_VALIDATION_002 |
422 | Missing title/body, unknown city, or city outside coverage |
Scheduled publishing
scheduled_at (unix) drives automatic publishing. A scheduler task (PublishScheduledBlogsMessage, every 1 minute via symfony/scheduler, consumed by messenger:consume scheduler_default) publishes every draft whose scheduled_at <= now and whose review_status is null or approved. A pending_review post is never auto-published — the medical-review gate wins over the schedule.
Medical-review gate
The content pipeline (clinicpro-crawler/content/) generates Persian health articles as drafts (status=draft, review_status=pending_review). A doctor/admin then approves or rejects each one before it goes public. The reviewer's identity is stored and shown on the post — the E-E-A-T signal for YMYL content. review_status=null posts (created manually by an admin) are outside this gate.
review_status values:
| Value | Meaning |
|---|---|
null |
Manual admin post — never entered the review workflow |
pending_review |
Awaiting a doctor's decision (in the queue) |
approved |
Reviewed and approved (usually also status=published) |
rejected |
Reviewed and rejected — stays a draft |
GET /api/v1/admin/blog/review-queue
List blog drafts awaiting review, newest first.
Permission: ROLE_ADMIN or ROLE_IMPORTER (read-only; the crawler panel shows this queue).
Query Parameters
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
page |
integer | ❌ | 1 | Page number |
limit |
integer | ❌ | 20 (max 50) | Items per page |
Response 200
Paginated ({ data:[...], meta:{ totalRecords, totalPages, currentPage } }). Each item is the blog list shape plus review_status, reviewer ({ uuid, name } or null), and topic_slug.
{
"success": true,
"data": [
{
"uuid": "...",
"title": "علائم سکته قلبی که نباید نادیده بگیرید",
"slug": "...",
"summary": "...",
"status": "draft",
"review_status": "pending_review",
"reviewer": null,
"topic_slug": "cardiology-heart-attack-symptoms",
"city": null,
"created_at": 1769000000
}
],
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
POST /api/v1/admin/blog/{uuid}/review
Record a doctor's review decision. Approving publishes the post by default.
Permission: ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Blog UUID |
Request Body (application/json)
{ "decision": "approved", "publish": true }
{ "decision": "rejected", "note": "ادعاهای پزشکی بدون منبع کافی" }
| Field | Type | Required | Description |
|---|---|---|---|
decision |
string | ✅ | "approved" or "rejected" |
note |
string | ⚠️ | Reviewer note. Required when decision=rejected. |
publish |
boolean | ❌ | On approval, publish immediately. Default true. false keeps it a draft. |
On approve: review_status=approved, reviewer/reviewed_at set, and status=published unless publish:false. On reject: review_status=rejected, note stored, status stays draft.
Response 200
Updated blog object (full toArray, including reviewer, reviewed_at, review_note).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
ERR_VALIDATION_002 |
422 | Invalid decision, or rejected without a note |
DELETE /api/v1/blog/{uuid}
Delete a blog post.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "message": "مقاله حذف شد" } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_NOT_FOUND_001 |
404 | Blog not found |
POST /file/upload/clinic_pro/blog/field_image
Upload blog post header image.
Permission: ROLE_ADMIN
Request
Content-Type: multipart/form-data
| Field | Type | Required | Description |
|---|---|---|---|
file |
binary | ✅ | Image (max 5MB) |
blog_uuid |
string (UUID) | ❌ | Auto-associate with blog post |
Response 200
{
"success": true,
"data": {
"url": "https://clinic-pro.ddev.site/uploads/blog/post_abc.jpg",
"uuid": "...",
"filename": "post_abc.jpg",
"filemime": "image/jpeg",
"filesize": 204800
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_AUTH_006 |
403 | Not admin |
ERR_FILE_001 |
422 | Invalid file type |
باطلسازی کش سایت عمومی (webhook خروجی)
سایت عمومی (nobat724_front) پاسخ GET /api/v1/blog/{slug} را با next: { revalidate: 3600, tags: [...] } کش میکند. بدون باطلسازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمیشد.
App\Blog\Service\BlogCacheInvalidator بعد از هر نوشتن یک درخواست خروجی میفرستد:
| نقطهٔ فراخوانی | فایل |
|---|---|
POST /api/v1/blog (create) |
BlogController::create |
PATCH /api/v1/blog/{uuid} |
BlogController::update |
POST /api/v1/admin/blog/{uuid}/review |
BlogController::review |
DELETE /api/v1/blog/{uuid} |
BlogController::delete (قبل از remove) |
POST/PATCH/DELETE /api/v1/representation/blog… |
RepresentationBlogController |
قرارداد درخواست (خروجی — مصرفکننده nobat724_front)
POST {REVALIDATE_WEBHOOK_URL}
X-Revalidate-Secret: {REVALIDATE_WEBHOOK_SECRET}
Content-Type: application/json
{"tags":["blog-<slug>","blog-<uuid>","blog-list"]}
بدنهٔ واقعی یک PATCH (خروجی شنوندهٔ تست):
{"tags":["blog-نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20","blog-56fd9a20-9594-4aa1-a651-346fa86720bd","blog-list"]}
پاسخ مورد انتظار سایت: 200 {"revalidated": true}؛ سکرت غلط 401؛ بدنهٔ بدون tags → 400.
متغیرهای محیطی
| متغیر | پیشفرض | معنا |
|---|---|---|
REVALIDATE_WEBHOOK_URL |
خالی | آدرس webhook سایت. خالی = هیچ درخواستی ارسال نمیشود |
REVALIDATE_WEBHOOK_SECRET |
خالی | باید با REVALIDATE_SECRET سمت سایت یکی باشد. خالی = ارسال نمیشود |
رفتار خطا — fail-open
timeout سه ثانیه است و هر خطای شبکه فقط با سطح warning لاگ میشود؛ ذخیره/حذف مقاله هرگز به خاطر شکست webhook شکست نمیخورد. تستشده: با URL غیرقابلدسترس، PATCH همچنان 200 برمیگرداند.