跳转到内容
Maple

订单

构建订单处理流程:连接门店、订阅 webhook、验证每次推送,并通过 accept、deny、ready 和 complete 调用推进每一笔订单。

这是 Maple 集成的核心。当顾客在您已连接的门店下单时,Maple 会把订单推送到您的 webhook;由您决定是否履行订单,并在订单推进时回报进度。整个流程不涉及支付,支付由 Maple 负责。

一个可用的订单集成包含两部分工作:

# 您要构建的内容 预计工作量
1 接收订单:一个带签名验证的 webhook 端点 约 1–2 天
2 处理订单:向 Maple 回调 accept / deny / status 约 1–2 天

如果还没看过,请先浏览 Maple 如何运作 了解对象模型,并运行 快速入门 获取密钥并连接一个测试门店。

步骤 1:连接门店

只有当您的应用成为某个门店的已连接接收方后,订单才会路由给您:

Terminal window
curl -X POST $MAPLE_BASE/locations/{locationId}/connection \
-H "Authorization: Bearer $MAPLE_KEY"

每个环境下的连接是独占的:如果其他应用已占用该门店,您会收到 409。对同一路径使用 GET 和 DELETE 可以查看和移除您的连接。

步骤 2:订阅 webhook

注册一个 HTTPS 端点以及您需要的事件类型。完整目录见 GET /v1/webhook_event_types;订单流程通常需要以下事件:

Terminal window
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.validation_requested", "order.cancelled"]
}'

响应中包含一个一次性签名密钥(mwhsec_…)。请妥善保存,它不会再次显示。URL 必须是公开的 HTTPS 地址,私有和内部地址会被拒绝。

步骤 3:验证每一次推送

每次推送都是 Stripe 风格的信封:

{
"object": "event",
"id": "evt_...",
"type": "order.notification",
"created": 1765432100,
"data": { "order_id": "ord_...", "location_id": "str_...", "...": "the order content — see Step 4" }
}

每次推送带有两个请求头:maple-webhook-id(事件 ID)和 maple-webhook-signature,格式为 t=<unix_seconds>,v1=<hex_hmac>。被签名的字符串是 {timestamp}.{subscription_id}.{notification_url}.{raw_body}。请对原始、未解析的请求体进行验证:

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));
}

上面的检查会拒绝过期的时间戳(超过约 5 分钟),以防重放。除此之外,请按信封中的 id 去重,因为推送保证至少送达一次。完整的可靠性约定,包括重试计划和事件记录,见Webhooks。

停机后补齐订单

如果您的接收端曾不可用(本地软件关闭或网络中断),请按以下步骤对账:

  1. 使用您上一次成功的检查点调用 GET /v1/orders?since=<timestamp>&limit=100(不含该时间点,ISO 8601 UTC)。使用 since 时,结果按从旧到新排序,has_more 表示还有下一页。
  2. 当 has_more 为 true 时,把 starting_after 设为上一页最后一个订单 ID 并重复请求。直到积压处理完毕,再推进您的检查点。
  3. 当您需要 Maple 为某个错过 webhook 的有效订单重新发布一条 order.notification 时,调用 POST /v1/orders/{orderId}/resend。它需要 webhooks:write 权限范围,会创建一次新的、已签名且带重试的推送,并且不会改变订单状态。终态订单(REJECTED、CUSTOMER_CANCELLED 或 STORE_CANCELLED)无法重发或恢复,会返回 400;请通过 GET /v1/orders?since=... 和 GET /v1/orders/{orderId} 对账。
  4. 如果需要保留原始事件身份,请使用事件记录,而不是 resend。

对于短时间的中断,等待也可以:重试计划会在大约 10 小时内重新投递失败的事件,之后才算彻底失败。

步骤 4:读取订单

order.notification 负载中的 data 是订单内容,包括 ID、顾客、商品明细和合计,因此您可以直接根据 webhook 开始备餐,无需再次调用。它与订单资源使用相同的字段名和以整数美分表示的金额,但对象更小:包含 order_id(而非 id)、location_id、fulfillment_type、scheduled_for、delivery_address、customer、line_items 和 totals。其中没有 object、id、livemode、created、status、payment_status、external_id 或 payment,商品明细也不含逐项 tax。order.validation_requested 携带相同的 data,另加 validation_id:

{
"order_id": "ord_...",
"location_id": "str_...",
"fulfillment_type": "delivery",
"scheduled_for": null,
"delivery_address": {
"street": "123 Main Street",
"unit": "Apt 4B",
"city": "New York",
"state": "NY",
"zip": "10001",
"notes": "Leave at the side door"
},
"customer": { "name": "Alex", "phone": "+14155551234", "phone_last_four": "1234" },
"line_items": [
{
"name": "Latte",
"menu_entity_id": "itm-latte",
"quantity": 1,
"base_price": 450,
"modifiers": [{ "name": "Oat milk", "menu_entity_id": "mod-oat", "quantity": 1, "price": 75 }]
}
],
"totals": { "currency": "USD", "subtotal": 525, "tax": 42, "surcharge": 0, "tip": 0, "delivery_fee": 599, "delivery_tip": 0, "total": 1166 }
}

GET /v1/orders/{orderId} 则返回完整的订单资源,额外包含 object、id、livemode、created、status、payment_status、external_id、payment 以及逐项 tax。您可以随时获取它,以得到权威的当前状态:

{
"object": "order",
"id": "ord_...",
"livemode": false,
"created": 1765432100,
"status": "PENDING",
"payment_status": "unpaid",
"fulfillment_type": "delivery",
"scheduled_for": null,
"location_id": "str_...",
"delivery_address": {
"street": "123 Main Street",
"unit": "Apt 4B",
"city": "New York",
"state": "NY",
"zip": "10001",
"notes": "Leave at the side door"
},
"customer": { "name": "Alex", "phone": "+14155551234", "phone_last_four": "1234" },
"line_items": [
{
"name": "Latte",
"menu_entity_id": "itm-latte",
"quantity": 1,
"base_price": 450,
"tax": 36,
"modifiers": [{ "name": "Oat milk", "menu_entity_id": "mod-oat", "quantity": 1, "price": 75 }]
}
],
"totals": { "currency": "USD", "subtotal": 525, "tax": 42, "surcharge": 0, "tip": 0, "delivery_fee": 599, "delivery_tip": 0, "total": 1166 },
"external_id": null,
"payment": { "provider": "stripe", "status": "unpaid", "payment_link_url": "https://..." }
}

请务必牢记以下几点:

  • 金额是以美分为单位的整数(USD)。 450 即 $4.50。切勿将其解析为浮点数。totals 和商品明细中的所有金额都遵循这一约定。
  • 合计已预先计算。 totals.total 是权威值,您无需重新计价。
  • 商品明细通过 menu_entity_id 引用您的菜单,其值是您为该菜品发布的 externalId。先发布菜单,这些值就能与您自己的商品目录对应。(菜单负载使用 camelCase;订单和 webhook 负载使用 snake_case,见约定。)
  • 加料选项(modifier)是直接选择的第一级选项。 更深层嵌套的选项在 v1 中不会展开到订单资源里。
  • 顾客数据刻意保持精简。 您会得到顾客的姓名和电话号码(phone 为 E.164 格式,另有 phone_last_four 用于显示)。外送订单还包含 delivery_address,内有街道、单元、城市、州、邮编和配送说明;自取订单或 Maple 没有已存地址时,其值为 null。只要该块有内容,六个地址键就都会存在。
  • 出单前先检查 scheduled_for。 ASAP 订单该值为 null。有值时(ISO 8601,UTC),表示顾客选定了取餐/送达时间;您会立即收到订单,因此应按 scheduled_for 安排备餐,而不是在收到订单时就开始。

步骤 5:做出决定并回报进度

随着订单推进,通过决定端点作出响应。除特别说明外,都不需要请求体:

调用 何时使用
POST /v1/orders/{orderId}/accept 您可以履行订单
POST /v1/orders/{orderId}/deny 您无法履行,可选 { "reason": "…" }
POST /v1/orders/{orderId}/ready 已备好,可取餐或交给骑手
POST /v1/orders/{orderId}/complete 已履行完成
POST /v1/orders/{orderId}/cancel 接单后您必须取消,可选 { "reason": "…" }
POST /v1/orders/{orderId}/status 细粒度状态转换(见下文)
Terminal window
curl -X POST $MAPLE_BASE/orders/{orderId}/accept \
-H "Authorization: Bearer $MAPLE_KEY"
{ "object": "order_decision", "order_id": "ord_...", "decision": "accept", "status": "received" }

如果您更喜欢使用单个端点,POST /v1/orders/{orderId}/status 接受明确的状态转换:

{ "status": "ACCEPTED" }

有效值:ACCEPTED、READY、IN_DELIVERY、FULFILLED、REJECTED、STORE_CANCELLED。各状态的含义及合法的状态转换,请参阅订单生命周期。

可选:预先验证订单

预先验证是可选的,通过订阅 order.validation_requested 来开启:

  • 如果您不订阅,就没有验证步骤。Maple 直接发送 order.notification,您的 accept/deny 是唯一的关卡。
  • 如果您订阅,Maple 会在发送通知之前,请您确认每笔订单是否可履行(菜品供应、价格可行性、能否写入 POS),并等待您的答复。因此选择启用就是一项承诺:您未及时验证的订单会被拒绝(见下文)。

机制就是这些:订阅即开关。

订阅后,请对每个 order.validation_requested 回复结果。请回传该事件 data 中的 validation_id,使结果与该次验证绑定;针对已过期或较早一次验证的结果会被拒绝并返回 404:

Terminal window
curl -X POST $MAPLE_BASE/orders/{orderId}/validation_result \
-H "Authorization: Bearer $MAPLE_KEY" \
-H "Content-Type: application/json" \
-d '{ "validation_id": "<来自事件的 validation_id>", "status": "valid", "partner_reference": "pos_quote_123" }'

若要阻止该订单:

{
"validation_id": "<来自事件的 validation_id>",
"status": "invalid",
"reason": { "code": "ITEM_AVAILABILITY", "explanation": "86'd", "item_external_ids": ["itm-latte"] }
}

valid 结果会让订单继续进入通知阶段;invalid 会在向顾客收款之前拦下订单。您有约 5 分钟的时间答复,请求随后过期(资源中带有 expires_at,约 300 秒后)。如果过期,Maple 会再请求一次验证;若第二次请求仍无答复,订单会被拒绝且不发送通知,因此请在您的处理程序能够可靠地按时答复之后再订阅。这与您在接单时进行的可用性检查相同,只是提前了,这样顾客就不会为您做不出的东西付款。

“完成”是什么样子

您构建的内容 您得到的结果
一个负责验证、去重和路由的 webhook 处理程序 实时推送的订单,合计已预先计算
Accept / deny / ready / complete / status 调用 顾客可看到订单的实时进度
(可选) 验证响应 问题订单在向顾客收款之前就被拦下

一个 webhook 加上少量决定调用。支付和定价由我们负责,Maple 团队在您的整个构建过程中都会提供支持。

下一步