# Citely Docs (/docs)
Chào mừng đến với tài liệu **Citely** — nền tảng SaaS viết & xuất bản nội dung SEO/GEO.
Tài liệu chia làm hai mảng:
## Bắt đầu nhanh [#bắt-đầu-nhanh]
* **Bạn là người dùng Citely?** → [Hướng dẫn sử dụng](/docs/guides)
* **Bạn là dev cần nhận bài từ Citely vào website?** → [Tổng quan Citely Connect](/docs/developer)
> Tài liệu này là *docs-as-code* (mã nguồn mở, viết bằng MDX). Nội dung máy-đọc-được tại
> [`/llms.txt`](/llms.txt) và [`/llms-full.txt`](/llms-full.txt) để trợ lý AI tra cứu nhanh.
# Trạng thái bất đồng bộ (building → live) (/docs/developer/async-publish)
Site động (`db`) publish xong là **live ngay**. Site tĩnh (`fs-build`) thì **bản chất bất
đồng bộ**: nhận bài → ghi file → CI/build (30–90s) → mới hiển thị. CPP mô hình hoá điều này
bằng **một field `status` + một flag `async`**, không phân mảnh.
## Ba trạng thái, một mô hình [#ba-trạng-thái-một-mô-hình]
```text
db adapter POST → 200 { status: "live" } (xong, hiển thị ngay)
fs-build POST → 202 { status: "building" } (đã nhận, đang build)
│
└─ Citely POLL `url` (HEAD) tới khi 200 → tự chuyển "live".
Site KHÔNG cần làm gì thêm.
```
## Mặc định — Citely poll (site không làm gì) [#mặc-định--citely-poll-site-không-làm-gì]
1. Receiver ghi bài → trả `202 { id, url, status: "building" }`.
2. Citely lưu một *delivery record* (permalink + trạng thái `building`).
3. Citely định kỳ `GET url` (HEAD/GET). Khi trả `200` → chuyển record sang `live`.
4. UI Citely hiện "Đang build → Đã xuất bản".
Đây là đường khuyến nghị: site tĩnh tối thiểu chỉ cần trả `202` rồi mặc kệ. Poll là cách
DUY NHẤT đang khả dụng hôm nay — receiver không cần expose thêm endpoint nào.
## Vì sao không dùng trạng thái đồng bộ "giả" [#vì-sao-không-dùng-trạng-thái-đồng-bộ-giả]
Có thể trả `200 {url}` ngay và coi như xong. Nhưng URL đó có thể `404` vài chục giây tới khi
build xong → user bấm vào thấy lỗi, và Citely index sớm một URL chưa tồn tại. `202 building`
* theo dõi là trung thực hơn: Citely chỉ báo "đã xuất bản" khi bài thật sự sống, và chỉ ping
Google index khi `live`.
Callback tức thì (site gọi Citely khi build xong) là **kế hoạch tương lai, chưa triển khai** — endpoint `POST /api/connect/live` chưa tồn tại. Hiện tại chỉ dùng đường poll ở trên.
# Mã lỗi CPP_* (/docs/developer/errors)
Receiver **không** trả lỗi tự do. Trả **mã chuẩn** để Citely map thẳng vào error-catalog
của nó và hiện đúng cách xử lý cho user — thay vì một "500 Internal Error" mù mờ.
Response lỗi: mã HTTP + JSON `{ "error": "", "message": "mô tả ngắn" }`.
## Mã lỗi receiver → Citely [#mã-lỗi-receiver--citely]
| HTTP | `error` code | Khi nào | Citely xử lý / hiện cho user |
| ---- | ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------- |
| 401 | `CPP_SIG_INVALID` | HMAC không khớp | "Secret lệch giữa Citely và site → xoay lại secret & cập nhật env." |
| 401 | `CPP_REPLAY` | `abs(now - ts) > 300s` | "Đồng hồ server site lệch > 5 phút → chỉnh NTP." |
| 400 | `CPP_VERSION_UNSUPPORTED` | Site chưa hỗ trợ `version`/`protocol` này | "Site cần nâng cấp receiver lên CPP v1." Citely chặn publish. |
| 422 | `CPP_VALIDATION` | Payload thiếu/sai field bắt buộc (kèm `field`) | Hiện đúng field lỗi (vd `title` vượt giới hạn Zod của site). |
| 409 | `CPP_CONFLICT` | Slug/externalId đụng bản ghi khác | "Trùng bài — kiểm tra externalId/slug." |
| 429 | `CPP_RATE_LIMITED` | Site tự giới hạn tần suất | Citely backoff & retry. |
| 500 | `CPP_STORAGE_FAILED` | Ghi thất bại (DB write / file) | "Lỗi phía site khi lưu bài." Citely retry. |
| 502 | `CPP_UNREACHABLE` | Không kết nối được endpoint (timeout, DNS, WAF chặn) | "Không gọi được site — kiểm firewall/WAF/allowlist IP Citely." |
Quy ước: mọi mã bắt đầu `CPP_`, SCREAMING\_SNAKE, **không đổi tên** khi đã phát hành (thêm
mã mới nếu cần nghĩa mới). Với `CPP_VALIDATION`, kèm `"field"` để Citely chỉ đúng chỗ:
```json
{ "error": "CPP_VALIDATION", "message": "title too long (max 70)", "field": "title" }
```
## Trạng thái publish (`status` trong echo) [#trạng-thái-publish-status-trong-echo]
| `status` | Nghĩa | Adapter điển hình |
| ---------- | -------------------------------------------- | ------------------ |
| `live` | Bài đã hiển thị công khai tại `url` | `db` (render động) |
| `building` | Đã nhận & lưu, đang chờ CI/build để hiển thị | `fs-build` |
Chi tiết vòng đời `building → live`: [Trạng thái bất đồng bộ](/docs/developer/async-publish).
## Đối xứng hai chiều [#đối-xứng-hai-chiều]
Citely dùng bộ mã này ở **cả hai chiều**: khi transport gặp lỗi lúc `POST` tới site, nó phân
loại về đúng `CPP_*` và surface qua hệ lỗi của Citely. Cùng một ngôn ngữ lỗi từ Citely → site
→ về Citely, nên user luôn thấy thông điệp hành động được, không phải log thô.
# Citely Connect — Tổng quan (/docs/developer)
**Citely Connect** là cách Citely đẩy bài viết đã hoàn thiện ra **website của bạn**.
**Citely Publish Protocol v1 (CPP v1)** là *hợp đồng kỹ thuật* mà website xây theo để nhận bài — Citely đặt chuẩn, website conform.
Citely đóng vai CMS/bộ não nội dung. Nhiều landing page hiện đại (Astro, Next.js, Hugo, React thuần, Laravel…) **không có CMS quản trị blog**. Citely Connect lấp đúng chỗ đó: website chỉ cần dựng **một endpoint** theo CPP v1 là sẵn sàng nhận bài — không phải tự viết admin, editor, auth, storage.
## Kết nối bằng một dòng (copy cho AI) [#kết-nối-bằng-một-dòng-copy-cho-ai]
Admin website **không cần tự code**. Copy đúng một dòng và dán cho AI của bạn (Claude Code / Cursor / Copilot):
```text
Fetch https://citely-seo.com/connect/v1/agent.md — rồi nối repo này với Citely.
```
AI đó sẽ `Fetch` spec, tự nhận diện stack, chọn adapter, dựng endpoint, map field theo schema repo của bạn, và in ra hướng dẫn đặt `CITELY_WEBHOOK_SECRET`.
## Ba tầng của chuẩn [#ba-tầng-của-chuẩn]
| Tầng | Ai định nghĩa | Nội dung |
| ----------------- | ---------------- | ------------------------------------------------------------------------- |
| 1. **Envelope** | Citely phát | 1 bài = 1 JSON canonical (html + markdown + jsonLd + meta + i18n + media) |
| 2. **Receiver** | Website xây | 1 endpoint: `GET` handshake năng lực + `POST` nhận bài |
| 3. **Vocabulary** | Cả hai nói chung | `status` + mã lỗi `CPP_*` |
## Chọn adapter theo "bạn muốn SỞ HỮU dữ liệu tới đâu" [#chọn-adapter-theo-bạn-muốn-sở-hữu-dữ-liệu-tới-đâu]
| Adapter | Ai lưu bài | Website xây | Lên sóng |
| --------------- | -------------------------- | --------------------------------------------------- | ----------- |
| `citely-api` 🥇 | **Citely** (bạn 0 backend) | chỉ 1 *reader* (fetch + render), KHÔNG cần receiver | ngay |
| `db` | DB của bạn | receiver upsert row → render động | ngay |
| `fs-build` | đĩa/repo của bạn | receiver ghi file + trigger build | sau rebuild |
* **`citely-api` = mặc định khuyến nghị:** bạn KHÔNG dựng endpoint — chỉ đăng ký site trong Citely, nhận read-key, rồi *đọc* read-API. Đây là adapter DUY NHẤT không cần receiver.
* **`db`** cho bên muốn dữ liệu nằm trong DB của mình (Cloudflare D1 › Neon / Turso › Supabase).
* **`fs-build`** cho site tĩnh self-host (ghi file + rebuild).
Adapter `git` (commit mỗi bài) và `citely-git` (GitHub App) **đã bỏ/gác lại** — ngàn commit + rebuild mỗi lần publish không scale.
## Cây quyết định [#cây-quyết-định]
```text
Bạn muốn SỞ HỮU dữ liệu tới đâu?
├─ "Chỉ cần có bài, khỏi backend" → citely-api (Citely host + read-API) ⭐ mặc định
├─ "Muốn bài trong DB của tôi" → db (D1/Neon/Turso › Supabase)
└─ "Khăng khăng self-host tĩnh" → fs-build (ghi file + rebuild)
```
## Tiếp theo [#tiếp-theo]
# Envelope (CPP v1) (/docs/developer/protocol-envelope)
Mỗi lần publish, Citely gửi **một** JSON body mô tả trọn vẹn một bài viết. Envelope là
**superset & self-sufficient**: mọi thứ để render một bài đều nằm trong một `POST` —
website **không bao giờ** phải gọi ngược Citely để lấy thêm.
## Quy tắc bất biến [#quy-tắc-bất-biến]
1. **Forward-compatible.** Citely có thể thêm field mới bất cứ lúc nào trong cùng `version`.
Receiver **PHẢI bỏ qua field lạ**, không được lỗi vì gặp field chưa biết.
2. **Optional trừ khi ghi "bắt buộc".** Field vắng mặt = không có dữ liệu đó; đừng suy diễn.
Chỉ `version`, `event`, `title`, `slug`, `status` là luôn có.
3. **`version`** = phiên bản envelope. CPP v1 dùng `version: 1`. Chỉ bump khi có *breaking*
change (đổi nghĩa/xoá field) — thêm field KHÔNG bump.
4. **HTML sạch, JSON-LD tách riêng.** `html` là thân bài ngữ nghĩa, **không** chứa
`