> ## Documentation Index
> Fetch the complete documentation index at: https://docs.maple.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook

> 订阅事件类型、验证 HMAC 签名的推送、对至少一次投递进行去重，并从事件记录中重放。

Webhook 会将受支持的业务事件通知给您的集成。注册一个公开的 HTTPS 端点以及您要处理的事件类型；Maple 会为每次推送签名并记录结果。持久化的业务事件推送在失败时会重试。已签名的测试和手动重放都是一次性操作，不会自行安排自动重试。

## 在控制台中管理推送

受邀的组织成员无需 API 密钥，即可打开 **Developers → app → Webhooks**。应用需要 `webhooks:read` 权限用于诊断，需要 `webhooks:write` 权限用于变更。出站操作还要求应用处于激活状态。

* **Endpoints：** 创建或编辑端点，保存一次性显示的签名密钥，主动发送测试，或禁用/重新启用端点。建议优先禁用，以保留推送历史。已暂停的应用仍可查看，其端点也仍可被禁用。
* **Deliveries：** 可按端点、事件类型、结果和时间范围筛选普通推送和已签名的测试推送。您可以查看汇总状态、尝试次数、最近一次响应状态、安全的错误信息和时间戳。这不是逐次尝试的时间线。显示的 URL 和端点状态是当前配置，而非历史目标地址的快照。
* **Events：** 查看可重放的业务事件元数据。**Review event** 会预览事件级别的重试；**Confirm retry** 会尝试向符合条件的目标重新推送，已成功的组合、已禁用的端点以及不再订阅的端点会被跳过。如果目标发生变化，请在确认前重新查看最新的预览。

历史记录默认显示 24 小时，最多支持 30 天，每页最多 100 行；游标在 15 分钟后失效。此读取窗口并不表示更早的记录已被删除。一次确认的控制台重放最多支持十个符合条件的端点，预览最多 100 个目标；超出的扇出会在发送前被拒绝。

控制台的测试和重放各有独立的服务端配额：每位用户/应用/环境每分钟三次，每个应用/环境每分钟十次。请遵守返回的冷却时间；界面不会自动重新提交。如果结果未知，请先检查您的接收端并刷新历史记录，然后再重试。

测试推送显示“已送达”，仅表示您的端点返回了 `2xx`，并不表示其签名已被验证。没有可重放业务事件的测试推送会提供 **Send another test**。待处理的事件记录或手动操作的失败，都不能证明还会有另一次尝试被安排。

控制台只显示元数据，不显示完整的事件负载、接收端响应正文、密钥或内部追踪信息。下文经过身份验证的公共事件 API 与此相互独立。**Webhook format and signature verification** 指南只包含可复制的合成示例；打开它不会发送请求，也不会用您的密钥生成签名。

### 测试完整的订单工作流

对于订单集成，请在沙盒控制台中打开 **Developers → app → Test orders**。选择一个已授权的测试门店，然后确认 **Create test order**。应用需要 `orders:write` 权限，必须处于激活状态，并且必须是该门店当前处于测试模式的订单接收方。Maple 必须能够准备一个有库存且没有必选加料选项的菜单项。

这会通过正常工作流创建一笔真实的到店付款自取订单，包括已配置的 webhook 和打印机集成。该操作不会向银行卡扣款。请使用独立的沙盒门店；它与 webhook 端点上的 **Send test** 不是一回事。创建频率按门店限制（默认每分钟三次），如果无法检查配额存储，则不可用。

具有 `orders:read` 权限时，结果跟踪器会显示与该应用、环境和门店关联的最近十笔订单的验证、通知投递和合作伙伴决定。新订单会在工作流记录验证或 webhook 事件后出现。如果结果不确定，请先刷新再重新发送；列表为空并不能证明创建失败。此工具不能替代使用您自己的密钥验证 API 请求。

商户的 **Location settings → Integrations → Connected apps** 仍保留连接和访问控制。开发者测试工具现已移至组织级控制台。

## 事件信封

每次推送都使用相同的 Stripe 风格信封。`data` 负载因事件而异：

```json theme={null}
{
  "object": "event",
  "id": "evt_...",
  "type": "order.notification",
  "created": 1765432100,
  "data": { "order_id": "ord_...", "location_id": "str_..." }
}
```

<ResponseField name="id" type="string">
  唯一的事件 id。**请基于它去重**——推送为至少一次。
</ResponseField>

<ResponseField name="type" type="string">
  事件类型。根据它来路由事件。
</ResponseField>

<ResponseField name="created" type="number">
  事件创建时的 Unix 时间戳（秒）。
</ResponseField>

<ResponseField name="data" type="object">
  事件专属的负载。`order.notification` 包含完整的订单内容；生命周期事件（`order.created`、`order.paid`、`order.cancelled`）包含摘要字段。各负载详见[Webhook 事件](/zh/developer-api/webhook-events)，如需权威的当前状态，请获取 `GET /v1/orders/{orderId}`。
</ResponseField>

## 事件类型

只订阅您会处理的事件。实时目录始终位于 `GET /v1/webhook_event_types`：

| 类型 | 触发时机 |
| - | - |
| `order.validation_requested` | Maple 在通知您之前，请您验证订单 |
| `order.notification` | 订单交给您履约——**您需要处理的事件** |
| `order.created` | 订单已创建——用于分析的生命周期信号，信息比通知事件更少 |
| `order.paid` | 付款已结算（可能晚于订单交接） |
| `order.cancelled` | 订单已取消——停止履约 |
| `booking.created` | 预订已创建，或首次从其提供方获取到 |
| `booking.confirmed` | 预订已确认 |
| `booking.updated` | 预订已变更 |
| `booking.cancelled` | 预订已取消 |
| `booking.declined` | 预订被餐厅拒绝 |
| `booking.seated` | 一组客人已入座 |
| `booking.completed` | 预订已完成 |
| `booking.no_show` | 预订被标记为爽约 |
| `waitlist_entry.created` | 一组客人加入候位名单 |
| `waitlist_entry.notified` | 已通知候位的客人桌位已准备好 |
| `waitlist_entry.seated` | 候位的客人已作为现场客人入座 |
| `waitlist_entry.removed` | 客人离开候位名单，或被餐厅移除 |
| `waitlist_entry.expired` | 已被通知的客人未在规定时间内回应 |
| `store.provisioned` | 某个门店已开通给您的应用 |
| `store.deprovisioned` | 某个门店已从您的应用撤销 |
| `store.status.changed` | 已连接门店的状态发生变化 |
| `menu.sync.completed` | 某个门店的菜单同步已完成 |
| `menu.sync.failed` | 某个门店的菜单同步失败 |

测试端点也会按需推送 `webhook.test`，让您在真实流量到来之前验证处理程序。

<Note>
  这些是离散的事件，而不是状态流。Maple 不会为每次订单状态变化发送 webhook——由您自己触发的状态转换（`accept`、`ready`、`complete`）不会回传，也没有逐次转换的事件。如需订单的当前状态，请读取 `GET /v1/orders/{orderId}`。参阅[订单生命周期](/zh/developer-api/concepts/order-lifecycle)。
</Note>

关于每个订单事件包含什么内容以及应订阅哪些，请参阅[Webhook 事件](/zh/developer-api/webhook-events)。预订事件需要 `bookings:read` 权限，并会针对已授权的门店推送；请参阅[Webhook 事件](/zh/developer-api/webhook-events)中的预订事件部分。

## 订阅

```bash theme={null}
curl -X POST $MAPLE_BASE/webhook_subscriptions \
  -H "Authorization: Bearer $MAPLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "notification_url": "https://your-app.example.com/maple/webhooks",
    "event_types": ["order.notification", "order.cancelled"]
  }'
```

```json Response theme={null}
{
  "object": "webhook_subscription.created",
  "subscription": { "object": "webhook_subscription", "id": "dws_...", "environment": "test", "status": "enabled", "...": "..." },
  "signing_secret": "mwhsec_..."
}
```

<Warning>
  `signing_secret`（`mwhsec_…`）**仅在创建时返回一次，之后不会再次返回。** 请立即妥善保存。如果丢失，请通过创建新的订阅来轮换。
</Warning>

`notification_url` 必须是公开的 HTTPS 地址——私有和内部地址会被拒绝。可对 `/v1/webhook_subscriptions/{id}` 使用 `GET`、`PATCH`（更新 URL、事件类型，或 `enabled`/`disabled` 状态）和 `DELETE` 来管理订阅。更新订阅不会改变其签名密钥。

### 多个订阅

您可以注册多个订阅。每个 **enabled** 且 `event_types` 包含已触发事件的订阅，都会收到各自独立签名的推送，并使用各自的签名密钥——因此允许多个订阅的 `event_types` 相互重叠。这样您可以按目的地分流：例如，将订单事件发送到您的履约服务，将 `menu.sync.*` 事件发送到另一个后台端点。[重放](/zh/developer-api/concepts/webhooks)遵循相同的路由，并会跳过已收到该事件的订阅。

## 验证签名

每次推送都带有两个标头：

* `maple-webhook-id`——事件 id。
* `maple-webhook-signature`——`t=<unix_seconds>,v1=<hex_hmac>`。

HMAC-SHA256 签名是针对一个签名字符串计算的，该字符串将时间戳、订阅和目标 URL 与正文绑定在一起——因此截获的签名无法被重放到其他订阅或 URL 上：

```
{timestamp}.{subscription_id}.{notification_url}.{raw_body}
```

请使用您的签名密钥重新计算，并以恒定时间进行比较。务必使用**未经解析的原始请求正文**——重新序列化 JSON 会改变字节内容，导致签名失效。

```ts theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyMapleWebhook(params: {
  signatureHeader: string; // maple-webhook-signature
  subscriptionId: string; // your subscription id (dws_…)
  notificationUrl: string; // the exact URL you registered
  rawBody: string; // the unparsed request body
  signingSecret: string; // mwhsec_…
  toleranceSeconds?: number; // default 300
}): boolean {
  const parts = Object.fromEntries(params.signatureHeader.split(',').map((p) => p.split('=')));
  const timestamp = Number(parts.t);
  const provided = parts.v1 ?? '';

  // Reject stale deliveries to prevent replay.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (!Number.isFinite(timestamp) || skew > (params.toleranceSeconds ?? 300)) return false;

  const signed = `${timestamp}.${params.subscriptionId}.${params.notificationUrl}.${params.rawBody}`;
  const expected = createHmac('sha256', params.signingSecret).update(signed).digest('hex');
  return expected.length === provided.length && timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
}
```

## 投递保证

* **至少一次。** 同一事件可能多次到达。请基于信封中的 `id` 去重，并确保您的处理程序是幂等的。
* **快速响应。** 请尽快返回 `2xx`，再异步处理耗时的工作。任何非 `2xx` 响应（或超时）都算作投递失败。
* **不保证顺序。** 不要假定事件按发生顺序到达；当顺序很重要时，请以订单资源为准进行核对。

### 重试与自动禁用

持久化的业务事件推送默认最多尝试 **9 次**——第一次立即进行，之后退避间隔逐渐增大。前几次尝试落在六分钟的 POS 决定窗口之内，而较长的尾部则用于在真正的故障之后进行对账：

| 尝试 | 与上一次的间隔 |
| - | - |
| 1 | 立即 |
| 2 | 10 秒 |
| 3 | 30 秒 |
| 4 | 2 分钟 |
| 5 | 5 分钟 |
| 6 | 15 分钟 |
| 7 | 1 小时 |
| 8 | 3 小时 |
| 9 | 6 小时 |

非 `2xx` 响应（或超时——每次尝试的上限为 15 秒）会使该次尝试失败。一个事件在 **全部 9 次**尝试中都失败，算作一次完全失败的推送。在 **连续 5 次完全失败的推送**（即五个不同的事件，每个都用尽了所有尝试）之后，该订阅会被自动**禁用**；只要有一次推送成功，计数器就会归零。待您的端点恢复正常后，通过 `PATCH` 将 `status` 改回 `enabled` 即可重新启用，然后[重放](/zh/developer-api/concepts/webhooks)您错过的内容。

## 事件记录

已发布的业务事件会被记录下来，用于对账和重放。合成的已签名测试推送有推送记录，但没有可重放的事件记录；请改为再次主动发送测试。

* `GET /v1/webhook_events`——您最近的事件，按时间倒序（最多 50 条）。适用于对账和调试。
* `GET /v1/webhook_events/{eventId}`——单个事件，包含其完整的 `data` 负载。
* `POST /v1/webhook_events/{eventId}/replay`——尝试向当前匹配的已启用订阅推送，跳过已记录成功的组合。它会复用事件 ID，且不会强制重新推送已成功的内容。并发尝试或网络/持久化结果不确定时，仍可能产生重复；接收端必须去重。手动尝试失败即为终态，不会安排自动重试。

如果您的服务宕机时间过长而错过了订单，当您希望保留原始事件标识时，请优先使用事件记录重放。若要直接补齐，请以 `since` 作为您上一次成功的检查点并向前读取：请求 `GET /v1/orders?since=<checkpoint>&limit=100`，当 `has_more` 为 true 时，使用 `starting_after=<last-order-id>` 重复请求。使用 `since` 时，结果按从旧到新排序，因此可以读完全部积压订单，而不会漏掉时间戳相同的订单。获取权威的订单资源，然后仅对错过 webhook 的有效订单调用 `POST /v1/orders/{orderId}/resend`。重发会创建一个新的已签名 `order.notification`，带有新的事件标识，且绝不会改变订单状态。处于终态的订单（`REJECTED`、`CUSTOMER_CANCELLED` 或 `STORE_CANCELLED`）无法重发或恢复；请使用 `GET /v1/orders?since=...` 和 `GET /v1/orders/{orderId}` 对账。

## 测试您的处理程序

```bash theme={null}
curl -X POST $MAPLE_BASE/webhook_subscriptions/{id}/test \
  -H "Authorization: Bearer $MAPLE_KEY"
```

这会推送一个已签名的 `webhook.test` 事件，并返回是否送达以及您的端点返回的 HTTP 状态：

```json theme={null}
{ "object": "webhook_test_result", "event_id": "evt_...", "delivered": true, "response_status": 200 }
```

用它来检查可达性和 `2xx` 响应，并在您自己的接收端验证签名。该响应不能证明签名处理正确。这只是一次尝试；`delivered` 为 false 或响应状态为 null，不应被理解为有自动重试正在等待。

## 检查清单

<Check>对每次推送，都针对原始正文验证 HMAC 签名，并拒绝过期的时间戳。</Check>
<Check>基于信封中的 `id` 去重；让处理程序保持幂等。</Check>
<Check>快速返回 `2xx`；异步处理。</Check>
<Check>只订阅您会处理的事件类型。</Check>
<Check>监控被自动禁用的订阅，并在故障后从事件记录中重放。</Check>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.