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 配置。

  • webhooks
    • defaultHeaders:你可以设置用于 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)

INFO

私有字段不会在负载中发送。

条目负载内容

在 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 负载仅携带条目自身的字段。

NOTE

管理面板中的单个操作可以触发多个事件。发布一个条目会先保存其草稿,因此 entry.update 会在 entry.publish 之前发送。

TIP

如果你需要具有不同内容或结构的负载,请从生命周期钩子中自己发送请求。

媒体负载内容

媒体事件发送与条目事件不同的信封:包含 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 请求淹没。
TIP

如果你想了解更多关于如何配合 Next.js 使用 webhook,请查看这篇专门的博客文章。