Webhooks
页面摘要: Webhooks 让 Strapi 在内容发生变化时通知外部系统,同时出于隐私考虑省略 User 类型。在
config/server中的配置用于设置默认头部和端点,以触发第三方处理。
Webhook 是应用用来通知其他应用某个事件已发生的一种构造。更准确地说,webhook 是一个用户定义的 HTTP 回调。使用 webhook 是告知第三方提供方开始某些处理(CI、构建、部署……)的好方法。
Webhook 的工作方式是通过 HTTP 请求(通常是 POST 请求)将信息传递给接收应用。
User 内容类型的 Webhooks
为了防止无意中将任何用户的信息发送给其他应用,Webhooks 对 User 内容类型不起作用。
如果你需要通知其他应用 Users 集合中的更改,可以通过使用 ./src/index.js 示例创建生命周期钩子来实现。
可用配置
你可以在 ./config/server 文件中设置 webhook 配置。
webhooksdefaultHeaders:你可以设置用于 webhook 请求的默认头部。此选项会被 webhook 自身设置的头部覆盖。
配置示例
JavaScript
module.exports = {
webhooks: {
defaultHeaders: {
"Custom-Header": "my-custom-header",
},
},
};
TypeScript
export default {
webhooks: {
defaultHeaders: {
"Custom-Header": "my-custom-header",
},
},
};
Webhooks 安全
大多数情况下,webhook 会向公开 URL 发起请求,因此有可能有人发现该 URL 并发送错误的信息。
为了防止这种情况发生,你可以发送一个带有身份验证令牌的头部。使用管理面板的话,你需要为每个 webhook 都这样做。
另一种方式是定义 defaultHeaders,将其添加到每个 webhook 请求中。
你可以通过更新 ./config/server 处的文件来配置这些全局头部:
简单令牌
JavaScript
module.exports = {
webhooks: {
defaultHeaders: {
Authorization: "Bearer my-very-secured-token",
},
},
};
TypeScript
export default {
webhooks: {
defaultHeaders: {
Authorization: "Bearer my-very-secured-token",
},
},
};
环境变量
JavaScript
module.exports = {
webhooks: {
defaultHeaders: {
Authorization: `Bearer ${process.env.WEBHOOK_TOKEN}`,
},
},
};
TypeScript
export default {
webhooks: {
defaultHeaders: {
Authorization: `Bearer ${process.env.WEBHOOK_TOKEN}`,
},
},
};
如果你是自己开发 webhook 处理器,现在可以通过读取头部来验证令牌。
验证签名
除了身份验证头部外,还建议对 webhook 负载(payload)进行签名,并在服务端验证签名,以防止篡改和重放攻击。为此,你可以遵循以下指南:
- 生成一个共享密钥并将其存储在环境变量中
- 让发送方基于原始请求体加上时间戳计算一个 HMAC(例如 SHA‑256)
- 在头部中发送签名(和时间戳)(例如
X‑Webhook‑Signature、X‑Webhook‑Timestamp) - 收到后,重新计算 HMAC 并使用恒定时间比较进行比对
- 如果签名无效或时间戳过旧,则拒绝,以减轻重放攻击
示例:验证 HMAC 签名(Node.js)
以下是一个最小的 Node.js 中间件示例(伪代码),展示了 HMAC 验证:
JavaScript
const crypto = require("crypto");
module.exports = (config, { strapi }) => {
const secret = process.env.WEBHOOK_SECRET;
return async (ctx, next) => {
const signature = ctx.get("X-Webhook-Signature");
const timestamp = ctx.get("X-Webhook-Timestamp");
if (!signature || !timestamp) return ctx.unauthorized("Missing signature");
// Compute HMAC over raw body + timestamp
const raw = ctx.request.rawBody || (ctx.request.body and JSON.stringify(ctx.request.body)) || "";
const hmac = crypto.createHmac("sha256", secret);
hmac.update(timestamp + "." + raw);
const expected = "sha256=" + hmac.digest("hex");
// Constant-time compare + basic replay protection
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
const skew = Math.abs(Date.now() - Number(timestamp));
if (!ok or skew > 5 * 60 * 1000) {
return ctx.unauthorized("Invalid or expired signature");
}
await next();
};
};
TypeScript
import crypto from "node:crypto"
export default (config: unknown, { strapi }: any) => {
const secret = process.env.WEBHOOK_SECRET as string;
return async (ctx: any, next: any) => {
const signature = ctx.get("X-Webhook-Signature") as string;
const timestamp = ctx.get("X-Webhook-Timestamp") as string;
if (!signature || !timestamp) return ctx.unauthorized("Missing signature");
// Compute HMAC over raw body + timestamp
const raw: string = ctx.request.rawBody || (ctx.request.body && JSON.stringify(ctx.request.body)) || "";
const hmac = crypto.createHmac("sha256", secret);
hmac.update(`${timestamp}.${raw}`);
const expected = `sha256=${hmac.digest("hex")}`;
// Constant-time compare + basic replay protection
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
const skew = Math.abs(Date.now() - Number(timestamp));
if (!ok || skew > 5 * 60 * 1000) {
return ctx.unauthorized("Invalid or expired signature");
}
await next();
};
};
这里有一些额外的外部示例:
可用事件
默认情况下,Strapi webhooks 可以由以下事件触发:
| 名称 | 描述 |
|---|---|
entry.create | 当创建内容类型条目时触发。 |
entry.update | 当更新内容类型条目时触发。 |
entry.delete | 当删除内容类型条目时触发。 |
entry.publish | 当内容类型条目发布时触发。* |
entry.unpublish | 当内容类型条目取消发布时触发。* |
entry.draft-discard | 当内容类型条目的草稿版本被丢弃时触发。* |
| 此事件未列在管理面板的 Webhooks 表单中,因此订阅它需要以编程方式创建 webhook。 | |
media.create | 当创建媒体时触发。 |
media.update | 当更新媒体时触发。 |
media.delete | 当删除媒体时触发。 |
review-workflows.updateEntryStage | 当内容在审核阶段之间移动时触发(请参阅审核工作流)。 |
| 此事件仅在 Strapi 的 (Enterprise 计划) 版本中可用。 | |
releases.publish | 当发布一个 Release 时触发(请参阅 Releases)。 |
| 此事件仅在 Strapi CMS 的 (Growth 计划) 或 (Enterprise 计划) 计划中可用。 |
*仅当此内容类型上启用了 draftAndPublish 时。
负载(Payloads)
私有字段不会在负载中发送。
条目负载内容
在 entry 内部,documentId 标识文档,而 id 标识该事件所涉及的版本。草稿及其已发布版本共享相同的 documentId,但具有不同的 id 值;丢弃草稿会创建一个新版本,因此 id 会再次变化。
对于所有条目事件(除了 entry.delete 和 entry.unpublish),在发送负载之前会重新读取该条目,因此它包含整个条目,并连同其所有关联关系、媒体、组件和动态区域一起被联表加载(populate)。可重复组件和动态区域项以其存储顺序发送,即在内容管理器中定义的顺序,并且它们各自携带自己的 id。
这种联表加载(population)是不可配置的:没有选项可以选择哪些字段被联表加载,也无法更改它们的排序方式。Strapi 4 的 webhooks.populateRelations 选项已在 Strapi 5 中被移除。
entry.delete 和 entry.unpublish 事件不会被重新读取:它们的 entry 对象是删除或取消发布请求本身返回的那个对象。从管理面板进行删除和取消发布展示了这在实践中意味着什么:entry.unpublish 负载携带完全联表加载(populate)的条目,而 entry.delete 负载仅携带条目自身的字段。
管理面板中的单个操作可以触发多个事件。发布一个条目会先保存其草稿,因此 entry.update 会在 entry.publish 之前发送。
如果你需要具有不同内容或结构的负载,请从生命周期钩子中自己发送请求。
媒体负载内容
媒体事件发送与条目事件不同的信封:包含 event、createdAt 和 media,没有 model 也没有 uid。media 对象是文件条目本身,包括为图像生成的 formats,并且它不携带该文件所附加的条目。
头部
当负载递送到你的 webhook URL 时,它将包含特定的头部:
| 头部 | 描述 |
|---|---|
X-Strapi-Event | 被触发的事件类型的名称。 |
entry.create
当创建新条目时触发此事件。
负载示例
{
"event": "entry.create",
"createdAt": "2026-09-09T08:49:26.158Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 1,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:26.150Z",
"publishedAt": null,
"openingHours": [
{
"id": 1,
"day": "monday",
"from": "09:00",
"to": "18:00"
},
{
"id": 2,
"day": "tuesday",
"from": "10:00",
"to": "19:00"
}
],
"blocks": [
{
"id": 1,
"body": "Ring the bell twice.",
"__component": "address.note"
},
{
"id": 1,
"email": "paris@example.com",
"phone": "0102030405",
"__component": "address.contact"
}
]
}
}
entry.update
当更新条目时触发此事件。
负载示例
{
"event": "entry.update",
"createdAt": "2026-09-09T08:49:27.382Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 1,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris 1er",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:27.377Z",
"publishedAt": null,
"openingHours": [
{
"id": 1,
"day": "monday",
"from": "09:00",
"to": "18:00"
},
{
"id": 2,
"day": "tuesday",
"from": "10:00",
"to": "19:00"
}
],
"blocks": [
{
"id": 1,
"body": "Ring the bell twice.",
"__component": "address.note"
},
{
"id": 1,
"email": "paris@example.com",
"phone": "0102030405",
"__component": "address.contact"
}
]
}
}
entry.delete
当删除条目时触发此事件。
负载示例
{
"event": "entry.delete",
"createdAt": "2026-09-09T08:49:33.501Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 3,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris 1er",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:28.603Z",
"publishedAt": null
}
}
entry.publish
当发布条目时触发此事件。
负载示例
{
"event": "entry.publish",
"createdAt": "2026-09-09T08:49:28.619Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 2,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris 1er",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:28.603Z",
"publishedAt": "2026-09-09T08:49:28.607Z",
"openingHours": [
{
"id": 3,
"day": "monday",
"from": "09:00",
"to": "18:00"
},
{
"id": 4,
"day": "tuesday",
"from": "10:00",
"to": "19:00"
}
],
"blocks": [
{
"id": 2,
"body": "Ring the bell twice.",
"__component": "address.note"
},
{
"id": 2,
"email": "paris@example.com",
"phone": "0102030405",
"__component": "address.contact"
}
]
}
}
entry.unpublish
当取消发布条目时触发此事件。
负载示例
{
"event": "entry.unpublish",
"createdAt": "2026-09-09T08:49:32.287Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 2,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris 1er",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:28.603Z",
"publishedAt": "2026-09-09T08:49:28.607Z",
"openingHours": [
{
"id": 3,
"day": "monday",
"from": "09:00",
"to": "18:00"
},
{
"id": 4,
"day": "tuesday",
"from": "10:00",
"to": "19:00"
}
],
"blocks": [
{
"id": 2,
"body": "Ring the bell twice.",
"__component": "address.note"
},
{
"id": 2,
"email": "paris@example.com",
"phone": "0102030405",
"__component": "address.contact"
}
]
}
}
entry.draft-discard {#entry-draft-discard}
当条目的草稿版本被丢弃时触发此事件,这会从已发布版本恢复草稿。
负载示例
{
"event": "entry.draft-discard",
"createdAt": "2026-09-09T08:49:31.065Z",
"model": "address",
"uid": "api::address.address",
"entry": {
"id": 3,
"documentId": "w7vfs319acmaxnurjk5vaaza",
"city": "Paris 1er",
"postalCode": "75001",
"createdAt": "2026-09-09T08:49:26.150Z",
"updatedAt": "2026-09-09T08:49:28.603Z",
"publishedAt": null,
"openingHours": [
{
"id": 5,
"day": "monday",
"from": "09:00",
"to": "18:00"
},
{
"id": 6,
"day": "tuesday",
"from": "10:00",
"to": "19:00"
}
],
"blocks": [
{
"id": 3,
"body": "Ring the bell twice.",
"__component": "address.note"
},
{
"id": 3,
"email": "paris@example.com",
"phone": "0102030405",
"__component": "address.contact"
}
]
}
}
media.create
当你在创建条目时或通过媒体界面上传文件时触发此事件。
负载示例
{
"event": "media.create",
"createdAt": "2026-09-09T09:03:55.238Z",
"media": {
"id": 1,
"documentId": "af1tfhdljtcm8076utj20ury",
"name": "photo.png",
"alternativeText": null,
"caption": null,
"focalPoint": null,
"width": 3024,
"height": 1646,
"formats": {
"thumbnail": {
"name": "thumbnail_photo.png",
"hash": "thumbnail_photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"path": null,
"width": 245,
"height": 133,
"size": 35.61,
"sizeInBytes": 35614,
"url": "/uploads/thumbnail_photo_27421e3364.png"
},
"small": {
"name": "small_photo.png",
"hash": "small_photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"path": null,
"width": 500,
"height": 272,
"size": 117.45,
"sizeInBytes": 117447,
"url": "/uploads/small_photo_27421e3364.png"
},
"medium": {
"name": "medium_photo.png",
"hash": "medium_photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"path": null,
"width": 750,
"height": 408,
"size": 233.53,
"sizeInBytes": 233529,
"url": "/uploads/medium_photo_27421e3364.png"
},
"large": {
"name": "large_photo.png",
"hash": "large_photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"path": null,
"width": 1000,
"height": 544,
"size": 387.81,
"sizeInBytes": 387809,
"url": "/uploads/large_photo_27421e3364.png"
}
},
"hash": "photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"size": 497.97,
"url": "/uploads/photo_27421e3364.png",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2026-09-09T09:03:55.235Z",
"updatedAt": "2026-09-09T09:03:55.235Z",
"publishedAt": "2026-09-09T09:03:55.235Z"
}
}
media.update
当你替换媒体或通过媒体界面更新媒体的元数据时触发此事件。
负载示例
{
"event": "media.update",
"createdAt": "2026-09-09T09:03:57.075Z",
"media": {
"id": 1,
"documentId": "af1tfhdljtcm8076utj20ury",
"name": "Overview of the Media Library",
"alternativeText": "The Media Library overview, in dark mode",
"caption": "Media Library",
"focalPoint": null,
"width": 3024,
"height": 1646,
"formats": {
// the four generated formats, as in the media.create payload above
},
"hash": "photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"size": 497.97,
"url": "/uploads/photo_27421e3364.png",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2026-09-09T09:03:55.235Z",
"updatedAt": "2026-09-09T09:03:57.073Z",
"publishedAt": "2026-09-09T09:03:55.235Z"
}
}
media.delete
仅当你通过媒体界面删除媒体时触发此事件。
负载示例
{
"event": "media.delete",
"createdAt": "2026-09-09T09:03:58.588Z",
"media": {
"id": 1,
"documentId": "af1tfhdljtcm8076utj20ury",
"name": "Overview of the Media Library",
"alternativeText": "The Media Library overview, in dark mode",
"caption": "Media Library",
"focalPoint": null,
"width": 3024,
"height": 1646,
"formats": {
// the four generated formats, as in the media.create payload above
},
"hash": "photo_27421e3364",
"ext": ".png",
"mime": "image/png",
"size": 497.97,
"url": "/uploads/photo_27421e3364.png",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2026-09-09T09:03:55.235Z",
"updatedAt": "2026-09-09T09:03:57.073Z",
"publishedAt": "2026-09-09T09:03:55.235Z"
}
}
review-workflows.updateEntryStage
(Enterprise 计划)
此事件仅在 Strapi 的 (Enterprise 计划) 计划中可用。 当内容移动到新的审核阶段时触发此事件(请参阅 Review Workflows)。
负载示例
{
"event": "review-workflows.updateEntryStage",
"createdAt": "2023-06-26T15:46:35.664Z",
"model": "model",
"uid": "uid",
"entity": {
"id": 2
},
"workflow": {
"id": 1,
"stages": {
"from": {
"id": 1,
"name": "Stage 1"
},
"to": {
"id": 2,
"name": "Stage 2"
}
}
}
}
releases.publish {#releases-publish}
(Growth 计划)(Enterprise 计划)
当发布一个 Release 时触发此事件。
负载示例
{
"event": "releases.publish",
"createdAt": "2024-02-21T16:45:36.877Z",
"isPublished": true,
"release": {
"id": 2,
"name": "Fall Winter highlights",
"releasedAt": "2024-02-21T16:45:36.873Z",
"scheduledAt": null,
"timezone": null,
"createdAt": "2024-02-21T15:16:22.555Z",
"updatedAt": "2024-02-21T16:45:36.875Z",
"actions": {
"count": 1
}
}
}
Webhook 处理的最佳实践
- 通过检查头部和负载签名来验证传入的请求。
- 为失败的 webhook 请求实现重试,以处理瞬时错误。
- 记录 webhook 事件以便调试和监控。
- 使用安全的 HTTPS 端点来接收 webhook。
- 设置速率限制,以避免被多个 webhook 请求淹没。
如果你想了解更多关于如何配合 Next.js 使用 webhook,请查看这篇专门的博客文章。