# Webhooks {#webhooks}

::: info หน้านี้สอนอะไร
แจ้งบริการอื่นเมื่อเนื้อหาเปลี่ยน พร้อมลายเซ็นและการส่งซ้ำที่ไม่หายแม้ server รีสตาร์ต

**ควรอ่านก่อน:** [Hooks](./hooks)
:::

แจ้งบริการอื่นเมื่อเนื้อหาเปลี่ยนแปลง เช่น build static site ใหม่ ล้าง cache ของ CDN หรือแจ้งเตือนไปยัง
ช่องแชต

```ts
export default defineConfig({
  webhooks: [
    // Every event of every collection and global, signed.
    { url: 'https://example.com/cms-hook', secret: process.env.WEBHOOK_SECRET },
    // Only publishing of posts, e.g. a Vercel or Netlify build hook.
    { url: process.env.BUILD_HOOK_URL!, events: ['publish', 'unpublish'], collections: ['posts'], globals: [] },
  ],
})
```

| ตัวเลือก | ค่าเริ่มต้น | |
|---|---|---|
| `url` | จำเป็น | ปลายทางที่ event จะถูก POST ไป |
| `events` | ทั้งหมด | `create`, `update`, `delete`, `publish`, `unpublish`, `draft` |
| `collections` | ทั้งหมดยกเว้น `users` | slug ของ collection ใช้ `[]` เพื่อไม่เลือกเลย บัญชีผู้ใช้จะถูกส่งเฉพาะเมื่อระบุ `users` ไว้ |
| `globals` | ทั้งหมด | slug ของ global ใช้ `[]` เพื่อไม่เลือกเลย |
| `secret` | — | ลงลายเซ็นทุก request (ดูด้านล่าง) |
| `headers` | — | header เพิ่มเติม เช่น token ที่ฝั่งรับใช้ตรวจสอบ |

## Events {#events}

| Event | เมื่อใด |
|---|---|
| `create` | มีการสร้างเอกสาร |
| `update` | เอกสารที่จัดเก็บไว้หรือ global มีการเปลี่ยนแปลง |
| `delete` | มีการลบเอกสาร |
| `publish` | status เปลี่ยนเป็น `published` (ส่งพร้อมกับ `create` หรือ `update`) |
| `unpublish` | status ไม่เป็น `published` อีกต่อไป |
| `draft` | บันทึกเฉพาะฉบับร่าง เนื้อหาที่แสดงบนเว็บไม่เปลี่ยน ([เวอร์ชัน](./drafts#versions)) |

การเผยแพร่ตาม[เวลาที่ตั้งไว้](./drafts#scheduled-publishing)จะส่ง event เดียวกัน

## Event ของคุณเอง {#your-own-events}

plugin และแอปส่ง event ของตัวเองได้ เช่น `order.paid` ของ[ร้านค้า](./ecommerce) ประกาศชื่อไว้ใน `events`
(plugin ประกาศให้เอง) ส่งด้วย `cms.emit()` แล้วระบุใน `events` ของ webhook event เหล่านี้ส่งไปเฉพาะ webhook ที่ระบุไว้

```ts
export default defineConfig({
  events: ['invoice.sent'],
  webhooks: [{ url: 'https://erp.example.com/hook', events: ['invoice.sent'], secret }],
})

cms.emit('invoice.sent', { number: 'INV-1' }, { collection: 'invoices', id: 7 })
```

body คือ `{ event, collection?, id?, doc, timestamp }` โดย `doc` คือสิ่งที่ส่งให้ `emit` การส่ง ลายเซ็น และการส่งซ้ำเหมือนเดิม

## Requests {#requests}

```http
POST /cms-hook
content-type: application/json
x-easy-cms-event: publish
x-easy-cms-delivery: 2f1c…               (the same on retries)
x-easy-cms-timestamp: 1761700000          (with `secret`: this attempt's time, Unix seconds)
x-easy-cms-signature-v2: v1=5c3e…         (with `secret`)
x-easy-cms-signature: sha256=9a0b…        (with `secret`, deprecated)

{ "event": "publish", "collection": "posts", "id": 12, "doc": { … }, "timestamp": "…" }
```

`doc` คือเอกสารที่จัดเก็บไว้ ครบทุกภาษา โดยไม่มี field ที่ซ่อนอยู่

### ตรวจลายเซ็น {#checking-the-signature}

เมื่อตั้ง `secret` การส่งแต่ละครั้งจะเซ็นด้วย HMAC-SHA256 ของ `<delivery>.<timestamp>.<body>` โดย body
เป็นแบบดิบตามที่ส่ง timestamp เป็นเวลาของการส่งครั้งนั้น ใครที่ดักจับ request ได้จึงเอาไปส่งซ้ำทีหลังไม่ได้
ใน Node ใช้ `verifyWebhook()` จาก `@easy-cms/core` ตรวจและได้ payload กลับมา

```ts
import { verifyWebhook } from '@easy-cms/core'

export async function POST(request: Request) {
  const body = await request.text() // body ดิบ ก่อน parse
  let payload
  try {
    payload = verifyWebhook(body, request.headers, process.env.CMS_WEBHOOK_SECRET!)
  } catch {
    return new Response('Bad signature', { status: 401 })
  }
  // …
}
```

- **ห้านาที** ปฏิเสธ timestamp ที่ห่างจากตอนนี้เกิน 5 นาที (เปลี่ยนได้ด้วย `{ toleranceSeconds }`) นาฬิกาของปลายทางจึงต้องตรง
- **ข้ามรายการที่จัดการแล้ว** ภายในห้านาทีนั้น request ที่ส่งซ้ำยังผ่านการตรวจ ให้เก็บ `x-easy-cms-delivery`
  ที่จัดการแล้วไว้สักพักและข้ามรายการซ้ำ การลองใหม่ใช้ id เดิม
- **หลาย secret** ส่งเป็น array ระหว่างเปลี่ยน secret ตัวไหนตรงก็ผ่าน

ภาษาอื่น ให้เทียบแบบใช้เวลาคงที่

```ts
import { createHmac, timingSafeEqual } from 'node:crypto'

const signed = `${headers['x-easy-cms-delivery']}.${headers['x-easy-cms-timestamp']}.${rawBody}`
const expected = `v1=${createHmac('sha256', secret).update(signed).digest('hex')}`
const given = headers['x-easy-cms-signature-v2']
const valid =
  expected.length === given.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given))
// และ |ตอนนี้ − x-easy-cms-timestamp| ≤ 300 วินาที
```

`x-easy-cms-signature` (HMAC ของ body อย่างเดียว) ยังส่งอยู่จนถึง 2.0 แต่ request ที่ถูกดักจับเอาไปส่งซ้ำได้
ให้เปลี่ยนไปใช้ `x-easy-cms-signature-v2`

## Delivery {#delivery}

webhook จะถูกส่งหลังจากบันทึกการเปลี่ยนแปลงแล้ว และจะไม่ทำให้การบันทึกช้าลงหรือล้มเหลว กรณี timeout
(10 วินาที), ข้อผิดพลาดของเครือข่าย, คำตอบ `429` และ `5xx` จะลองส่งใหม่ ส่วนคำตอบ `4xx` อื่นๆ
จะถูกบันทึก log และไม่ลองใหม่

- ลองใหม่เร็วๆ สองครั้งใน process ที่ทำการเปลี่ยนแปลง (หลัง 1 วินาทีและ 5 วินาที)
- การส่งแต่ละครั้งจะถูกบันทึกลงฐานข้อมูล (ตารางภายใน `webhook-deliveries`) ก่อนส่งครั้งแรก และลบออกเมื่อสำเร็จ
  จึงไม่หายแม้ restart หรือฟังก์ชัน serverless หยุดทำงานกลางคันระหว่างส่ง ถ้า process หยุดระหว่างส่ง
  การรันรอบถัดไปจะส่งต่อให้หลังผ่านไป 5 นาที
- หลังจากนั้นจะลองใหม่พร้อมกับ[งานที่ตั้งเวลาไว้](./drafts#scheduled-publishing): หลัง 1 นาที, 5 นาที,
  30 นาที, 2, 6 และ 12 ชั่วโมง server จะรันทุกนาทีเอง ส่วน serverless ให้ cron เรียก `<api>/jobs/run`
- หลังครั้งสุดท้าย (ประมาณหนึ่งวัน) การส่งจะถูกเก็บไว้เป็น `state: 'failed'` พร้อม error ล่าสุด
  และไม่ลองใหม่อีก admin ดูได้ที่ **ตั้งค่า → การส่ง** และกดส่งซ้ำหรือลบได้ ([ตรวจสุขภาพระบบ](./health-checks#failed-webhooks)) รายการที่ล้มจะถูกลบเมื่อเกิน 30 วัน

ทุกครั้งที่ลองใหม่จะส่ง body และ `x-easy-cms-delivery` เหมือนเดิม (timestamp และลายเซ็นใหม่) ปลายทางจึงข้ามการส่งที่เคยจัดการแล้วได้
การเพิ่ม `webhooks` ใน config จะเพิ่มตาราง `webhook-deliveries` ให้สร้าง migration เหมือนการเปลี่ยน config ทั่วไป

บนแพลตฟอร์ม serverless ฟังก์ชันอาจหยุดทำงานก่อนที่ webhook จะถูกส่ง: ให้เรียก
`await cms.flushWebhooks()` ก่อน return เมื่อเขียนข้อมูลผ่าน Local API บนแพลตฟอร์มเหล่านั้น
ส่วน `await cms.retryWebhooks()` จะลองส่งรายการที่บันทึกไว้ซึ่งถึงเวลาแล้ว (`runJobs()` ทำทั้งส่วนนี้และการเผยแพร่ตามเวลา)

## ขั้นต่อไป {#next-steps}

- [ฉบับร่าง เวอร์ชัน และการตั้งเวลา](./drafts): การเผยแพร่ทำให้เกิดอะไร
- [CLI](./cli): ส่งซ้ำจาก cron
