API 文档

快速开始

1. 获取 APP ID 和 APP Secret

喵福禄使用APP ID 和 APP Secret 来验证 API 调用信息:

  1. APP ID 用于识别您的应用程序
  2. APP Secret 用于验证您的应用,您需要使用它和 APP ID 来交换 Access Token,以便调用其它 API

您可以按照以下步骤获取 APP ID 和 APP Secret:

  1. 登录您的管理后台账号,如果您还没有账号,请先注册一个新账号
  2. 进入 API 文档页面
  3. 点击管理应用按钮,创建一个新的应用
  4. 在创建应用页面中,请填写应用的名称,回调地址等信息,然后点击创建应用按钮
  5. 创建成功后,您就可以在应用中看到您的 APP ID 和 APP Secret,如下图所示:
get-secret

温馨提示:

  1. APP Secret 只会显示一次,请您务必妥善保管
  2. 请不要将您的 APP Secret 泄露给他人,以免造成不必要的损失
  3. 如果您的 APP Secret 不慎泄露,请及时在 管理应用 中更改
  4. 如果您忘记了 APP Secret,可以在 管理应用 中重新生成

2.获取 Access Token

在您开始使用 喵福禄 的 REST API 之前,需要先用您的 APP ID 和 APP secret 换取 Access Token 。 Access Token 将用于调用 REST API 时对您的应用进行身份验证。

温馨提示: 您可以使用您熟悉的任何编程语言来调用福禄喵 OAuth API 换取 Access Token

以下是使用 cURL 换取 Access Token 的示例,供您参考:

请求
1curl -X POST "https://api.meowflow.com.cn/v1/authentication/token" \
2  -H "Content-Type: application/x-www-form-urlencoded" \
3  -d "grant_type=client_credentials&client_id=YOUR_APP_ID&client_secret=YOUR_APP_SECRET"
  1. YOUR_APP_ID:替换为您的 APP ID
  2. YOUR_APP_SECRET:替换为您的 APP secret

如果请求成功,您将收到一个 JSON 响应,其中包含 access_token token_type 等信息

响应
1{
2  "code": 0,
3  "message": "OK",
4  "data": {
5    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjU4NzM0NzQsInN1YiI6IjE1MjA2Njg0MDM0NzA2MjI3MyIsImFwcElkIjoiMTUyMDY2ODQwMzQ3MDYyMjc1Iiwic2NvcGVzIjpbIndvcmtmbG93VGFzay5jcmVhdGUiLCJ3b3JrZmxvd1Rhc2sucmVhZCJdfQ.lMNLQCDfCE24f8FT3ixzDvB7rFtGLkC9wtBYyRF7IF8",
6    "token_type": "Bearer",
7    "expires_in": 7199,
8    "scope": "workflowTask.write workflowTask.read",
9    "app_id": "152066840347062275"
10  }
11}

当您进行 REST API 调用时,需要在 Authorization 标头中将 ACCESS_TOKEN 替换为你的 Access Token : -H Authorization: Bearer ACCESS_TOKEN

当您的 Access Token 过期时,您需要再次调用 /v1/authentication/token 来换取新的 Access Token 。

3. 创建工序任务

使用 Access Token调用 创建工序任务API, 创建一个工序任务

请求
1curl -X POST "https://api.meowflow.com.cn/v1/workflowTask" \
2  -H "Authorization: Bearer ACCESS_TOKEN" \
3  -H "Content-Type: application/json;charset=UTF-8" \
4  -d '{
5    "items": [
6      {
7        "workflowId": "string",
8        "customId": "string",
9        "data": {}
10      }
11    ]
12  }'
  1. ACCESS_TOKEN:替换为您的 Access Token
  2. items:替换为您的工序任务数据

应用授权

喵福禄 REST API 采用 OAuth 2.0 中的 Access Token 来验证请求的合法性。我们使用的授权方式是OAuth 2.0 Client Credentials Grant

在您开始使用 喵福禄 的 REST API 之前,需要先用您的 APP ID 和 APP secret 换取 Access Token 。如果您还没有这些信息,可以参考快速开始指南来获取。

以下是一个使用 cURL 换取 Access Token 的示例,希望能为您提供帮助:

当然,您也可以使用任何您熟悉和喜欢的编程语言来调用喵福禄的 OAuth API 换取 Access Token

请求
1curl -X POST "https://api.meowflow.com.cn/v1/authentication/token" \
2  -H "Content-Type: application/x-www-form-urlencoded" \
3  -d "grant_type=client_credentials&client_id=YOUR_APP_ID&client_secret=YOUR_APP_SECRET"
  1. YOUR_APP_ID:替换为您的 APP ID
  2. YOUR_APP_SECRET:替换为您的 APP secret

如果请求成功,您将收到一个 JSON 响应,其中包含 access_token token_type 等信息

响应
1{
2  "code": 0,
3  "message": "OK",
4  "data": {
5    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjU4NzM0NzQsInN1YiI6IjE1MjA2Njg0MDM0NzA2MjI3MyIsImFwcElkIjoiMTUyMDY2ODQwMzQ3MDYyMjc1Iiwic2NvcGVzIjpbIndvcmtmbG93VGFzay5jcmVhdGUiLCJ3b3JrZmxvd1Rhc2sucmVhZCJdfQ.lMNLQCDfCE24f8FT3ixzDvB7rFtGLkC9wtBYyRF7IF8",
6    "token_type": "Bearer",
7    "expires_in": 7199,
8    "scope": "workflowTask.write workflowTask.read",
9    "app_id": "152066840347062275"
10  }
11}

当您进行 REST API 调用时,需要在 Authorization 标头中将 ACCESS_TOKEN 替换为你的 Access Token:-H Authorization: Bearer ACCESS_TOKEN

当您的 Access Token 过期时,您需要再次调用 /v1/authentication/token 来换取新的 Access Token 。

温馨提示:

为了保证系统的稳定性和安全性, 喵福禄 限制了同一时间段内有效的 Access Token 数量不可超过 10 个。如果超过这个限制,您可能会收到 429 Too Many Requests 错误。 您可以参考我们的 API 速率限制#关于获取 Access Token 的特别说明 文档来了解更多详情。

REST API 请求

当您需要向 喵福禄 发出 REST API 请求时,你需要结合 HTTP 的 GETPOSTPUTPATCHDELETE 方法、API 服务的 URL、要操作的资源 URI,以及一个或多个 HTTP 请求头

喵福禄 REST API 服务域名:

  • 正式环境: https://api.meowflow.com.cn
  • 沙箱环境: https://api-test.meowflow.com.cn

在某些情况下,您可能需要在 GET 请求中包含公共查询参数,用于对响应数据进行过滤、分页和排序等操作。大多数 POST、PUT 和 PATCH 请求则需要一个 JSON 格式的请求体。

温馨提示:

请记得对所有 URL 参数使用 URL encoding 进行编码,以确保特殊字符能够正确传递。

以下是一个查询工序任务详情的示例:

请求
1curl -v -X GET "https://api.meowflow.com.cn/v1/workflow-task/custom?customId=1234567890" \
2  -H "Content-Type: application/json;charset=UTF-8" \
3  -H "Authorization: Bearer <ACCESS_TOKEN>"

公共查询参数

对于大多数 REST API 的 GET 请求,你可以在请求 URI 上包含一个或多个查询参数,以过滤、分页并对 API 响应中的数据进行排序。 对于更详细的过滤参数,请参阅各个 GET 请求 API 文档。

如:分页可以使用 pagepageSize 参数,排序可以使用 orderBysortBy 参数,日期筛选可以使用 startTimeendTime 参数。

温馨提示:

  1. 并非所有 API 都支持这些参数,具体需要查看对应 API 支持字段。
  2. 日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00 或 2023-09-01T12:00:00Z。
  3. 所有的 URL 参数都应该使用 URL encoding 格式编码,以确保特殊字符正确传递。
字段类型注释是否必填默认值备注
startTimestring日期筛选 - 开始时间NO""日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00
endTimestring日期筛选 - 结束时间NO""日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00
orderBystring排序字段NOid具体查看对应 API 支持字段
sortBystring排序方式NOdesc排序方式:asc=升序,desc=降序
pageint页码NO1最小值:1,最大值:100
pageSizeint每页数量NO20最小值:1,最大值:100

HTTP 请求头

常用的 HTTP 请求头有:

  • Content-Type:用于指定请求体的格式,如 application/json;charset=UTF-8
    几乎所有的 API 都以 JSON 格式接收数据,所以 Content-Type 通常设置为 application/json;charset=UTF-8
  • Accept:用于指定响应体的格式,如 application/json;charset=UTF-8
    几乎所有的 API 都以 JSON 格式返回数据,所以 Accept 通常设置为 application/json;charset=UTF-8
  • Authorization:用于验证请求的身份,格式为 Bearer ACCESS TOKEN。其中 ACCESS TOKEN 可在 应用授权 中获取

REST API 响应

每个 REST API 请求均会返回一个包含 HTTP 状态码和 JSON 格式响应体的响应。此响应体遵循统一的字段结构,旨在清晰传达请求的处理结果。

HTTP 状态码:用于概括请求的成功或失败状态。

响应体结构:

  • code :业务状态码,用于进一步细分请求结果。
    • 成功时,code 为 0,表示操作成功。
    • 失败时,code 为非零值,表示特定错误。
  • message :与 code 对应的描述信息,提供请求结果的详细说明。
    • 成功时,message 为"OK",表示操作成功完成。
    • 失败时,message 包含错误描述,帮助理解错误原因。
  • data :响应数据内容,可能是一个对象、数组或者 null。具体内容取决于请求的 API。
字段类型注释是否必填备注
codeint业务状态码YES参考:业务通用错误码
messagestring业务状态码描述YES
dataarray | object | null响应数据内容YES

响应示例:

请求成功
1{
2  "code": 0,
3  "message": "OK",
4  "data": {
5    "id": "326472364",
6    "name": "jack.lai"
7  }
8}
请求失败
1{
2  "code": 100002,
3  "message": "invalid param",
4  "data": null
5}

公共响应头

所有的 REST API 响应都会包含以下 HTTP 头信息:

  • Content-Type:用于指定响应体的格式,如 application/json;charset=UTF-8
  • X-RateLimit-Limit:用于指定该 API 允许的最大请求数,单位为次
  • X-RateLimit-Remaining:用于指定该 API 允许的剩余请求数,单位为次
  • X-Trace-Id:用于指定请求的追踪 ID,用于调试和排错
  • Trace-Id:用于指定请求的追踪 ID,用于调试和排错,其值与 X-Trace-Id 相同
  • Traceparent:比 Trace-Id 更详细的追踪信息,用于调试和排错,遵循W3C Trace Context标准

成功的响应

对于成功的请求,HTTP 状态码为 2xx

状态码注释
200 OK请求处理成功
201 Created一个 POST 方法成功创建了一个资源
202 Accepted请求已被接受,将进行异步处理。如:批量创建工序流任务
204 No Content服务器成功处理了请求,但没有返回任何内容。通常在 DELETE 请求中使用。

失败的响应

对于失败的请求,如:请求参数错误等由于客户端原因导致的请求失败,HTTP 状态码为 4xx或者由于我们的服务端原因导致的请求失败,HTTP 状态码为 5xx

业务通用错误码

以下是服务端常用错误码列表,部分未列入的错误码可以在具体 API 接口文档中查询到

HTTP 4xx 错误码

状态码业务错误码说明排错建议
400100002invalid param请求参数缺失或者有误,更多错误信息请参阅请求返回的 message
400100004bad request 或 invalid param传参错误,请确保请求信息、请求数据格式等是正确的
401110007用户 Access Token 无效未传递 API访问令牌 或 已过期,需要传递有效的访问令牌或重新生成访问令牌
401110009Unauthorized用户可能携带认证信息 ,但认证失败
403100003forbidden权限不足,拒绝访问,请确保当前的 Access Token 具有操作该资源的权限
404100008resource not found资源未找到,请检查传递的 资源ID 是否正确,资源是否成功被创建
405100005method not allowedHTTP 方法不支持,检查是否使用错误的请求方式,如:API 要求 POST 却使用 GET
409100018resource conflict该 API 无法完成请求的操作,因为它与当前正在处理的另一个请求冲突。请稍后重试该请求。
429100006too many requests应用被限流,稍后再试,适当减小请求频率

HTTP 5xx 错误码

Status code业务错误码说明排错建议
500100000通用错误查看 message 或 联系开发者
500100001internal service error内部错误,请稍后重试 或 联系开发者
500100019internal service error内部错误,请稍后重试 或 联系开发者
503100007service unavailable当前服务不可用,处于维护阶段,稍后再试

错误响应示例

请求
1curl -X POST "https://api.meowflow.com.cn/v1/authentication/token" \
2  -H "Content-Type: application/x-www-form-urlencoded" \
3  -d "grant_type=client_credentials&client_id=BAD_APP_ID&client_secret=APP_SECRET"

由于 client_idBAD_APP_ID 是错误的,这个请求将返回 HTTP 401 Unauthorized 状态码和一个 JSON 响应体,其中列出了错误代码和错误描述:

响应
1{
2  "code": 110009,
3  "message": "APP Client 身份验证失败",
4  "data": null
5}

授权范围控制

授权范围控制是 喵福禄 平台中保障 API 安全访问的一个重要机制。通过这个机制,我们希望能够为您提供更精细的权限划分,以便更好地控制每个 APP 对 API 的调用权限,从而保护您的资源访问权限与数据安全。

在 REST API 中,我们采用了 Access Scope 机制来管理 API 访问权限。每个 Access Scope 都是一个独特的字符串标识,用于明确界定 API 的操作范围。 这些范围由标准化的权限标识符组成,遵循 OAuth 2.0 标准中的 OAuth Scopes 定义规范。

当您通过 REST API 与 喵福禄 交互时,需要使用 Access Scope 进行身份验证。这个Access Scope 是通过 OAuth 2.0 协议安全获取的,并且内置了 APP 的权限范围信息。 在发起 REST API 请求时,您只需将有效的 Access Token 作为 Authorization 字段的值,附加在请求头中即可。 喵福禄 会确保只有那些权限范围与请求资源相匹配的 Access Scope,才能成功解锁对应资源的访问权限。

以下是一个 Access Scope 的示例,供您参考:

workflowTask.write workflowTask.read 表示授权的 Access Token 拥有对工序流任务进行编辑(write)和读取(read)的权限。

温馨提示:

为了帮助您顺利获取并使用 Access Token,建议您参阅 应用授权。在那里,您可以找到详细的获取流程和使用方法。 我们相信这将帮助您更快速、更安全地集成并使用喵福禄提供的 REST API 服务。

工序流任务权限

workflowTask.write

  • 权限标识符:workflowTask.write
  • 权限点描述:此权限允许对工序流任务进行编辑操作,包括创建、取消等。
  • 相关 API:创建工序流任务

workflowTask.read

  • 权限标识符:workflowTask.read
  • 权限点描述:此权限允许读取工序流任务信息,包括获取任务列表、任务详情等。
  • 相关 API:获取工序流任务

REST API 速率限制

为了保障 喵福禄 服务的稳定运行和各用户之间资源的公平分配,REST API 实施了一些速率限制措施。以防止恶意请求或者过度使用 API 资源,我们深知这可能会给您带来一些不便,但请相信这是为了给所有用户提供更好的服务体验。 我们希望开发者在使用 REST API 时,需要遵守速率限制规则,有效地限制调用和合理地进行重试请求,以避免因频繁请求而导致的 API 调用失败。 让我们一起来了解一下这些规则,以便您能更好地使用我们的 REST API。

速率限制策略

速率限制是指在一定时间内允许的 REST API 请求次数。我们采用了一种叫做 令牌桶算法(Token Bucket Algorithm)的策略。 这个算法很有趣,它可以让您在一段时间内灵活地使用 REST API,而不是严格限制每秒的请求数。

速率限制的具体细节

为了帮助您更好地规划 API 使用,我们为您准备了一些具体的数字供参考:

  • REST API 是按分钟来计算速率限制的。
  • 每个 APP 的速率限制是独立的,互不影响。
  • 默认情况下,每个 APP 每分钟可以发送 60 次请求,每秒钟会"补充"2 次请求机会。
  • 如果不小心超过了限制,API 会返回 429 Too Many Requests 错误。请不用担心,这是很常见的情况!

为了帮助您更好地管理请求,每次 API 响应时, 喵福禄 都会在 HTTP 头信息中提供以下信息:

  • X-RateLimit-Limit:您的速率限制最大值。
  • X-RateLimit-Remaining:您还剩下的请求机会次数。

如果遇到 429 Too Many Requests 错误,REST API 还会告诉您:

  • Retry-After:需要等待多少秒才能继续请求。

关于获取 Access Token 的特别说明

获取 Access Token 的规则稍有不同,我们为您列出了具体细节:

  • 每个 APP 每分钟最多可以请求 20 次。
  • 同一时间段内,最多可以有 10 个有效的 Access Token。如果超过这个数量,您可能会收到 429 Too Many Requests 错误,这时您可以尝试等待一段时间再次请求。

喵福禄的"喵喵币"系统:生动解释令牌桶算法

想象下,现在 喵福禄 化身为一家神奇的猫咖,我们使用"喵喵币"来管理访客流量。这就像令牌桶算法的工作方式:

喵喵币系统(令牌桶)运作规则
  1. 我们有一个"喵喵币储蓄罐",最多可存60枚喵喵币。(令牌桶最大容量)
  2. 每秒钟,我们会往储蓄罐里添加2枚新的喵喵币,直到达到上限。(令牌桶填充速率)
  3. 每位访客进入时需要借用一枚喵喵币。(API请求)
  4. 访客离开时,会归还喵喵币,但储蓄罐不会超过最大容量。(API请求完成)
  5. 如果储蓄罐里有喵喵币,访客可以立即进入;如果没有,需要等待。(请求速率限制:429 Too Many Requests)

一天中的喵咖时光

让我们看看在不同时段,猫咖是如何运作的:

  1. 早晨开业(桶满状态):
    • 9:00,猫咖开门,储蓄罐里有60枚喵喵币。
    • 来了20位访客,他们借走20枚喵喵币入场,储蓄罐还剩40枚。
  2. 正常运作(动态平衡):
    • 9:01-9:30,每秒都有2枚新喵喵币加入储蓄罐。
    • 新访客陆续到来,借走喵喵币;早来的访客离开,归还喵喵币。
    • 储蓄罐的喵喵币数量在动态变化,但始终有喵喵币可用。
  3. 突发高峰(令牌快速消耗):
    • 10:00,突然涌入50位访客!
    • 储蓄罐里的喵喵币快速减少,刚好被全部借走。
    • 服务员微笑着说:"欢迎光临,请享受您的时光~"
    • 这体现出令牌桶算法的灵活特性,可以应对突发的流量高峰。
  4. 超出限制(令牌耗尽):
    • 10:01,又来了20位访客,但储蓄罐已经空了。
    • 服务员歉意地说:"非常抱歉,请稍等片刻,我们正在等待喵喵币归还或生成新的~"
    • 这相当于触发了 429 Too Many Requests 错误。
  5. 逐渐恢复(令牌重新积累):
    • 10:02开始,一些早先的访客离开,归还了喵喵币。
    • 同时,每秒也在添加2枚新喵喵币。
    • 等候的访客可以逐渐入场,但有些访客可能需要等待几秒。
    • 服务员解释道:"感谢您的耐心,很快就能为您服务~"
  6. 平稳期(动态平衡恢复):
    • 10:10后,进出访客数量趋于平衡。
    • 归还的喵喵币和新生成的喵喵币能够满足新访客的需求。
    • 储蓄罐中的喵喵币数量在一个相对稳定的范围内波动。
友好提示

每次访客"光临"时,我们都会告诉 Ta:

  • 储蓄罐还剩多少喵喵币X-RateLimit-Remaining:剩余令牌数
  • 储蓄罐最多能存多少喵喵币X-RateLimit-Limit:令牌桶容量
  • 如果需要等待,我们会告诉 Ta 大约要等多久Retry-After:预计等待时间

这个"喵喵币"系统帮助我们在应对突发高峰时保持灵活,同时确保长期的稳定服务。它模拟了API请求的动态特性,既考虑了新请求的到来,也考虑了旧请求的完成。

这就是 喵福禄 REST API 速率限制的工作原理。我们希望通过这个生动的例子,能让您更容易理解这个概念。

加签与验签机制

加签与验签机制是保障您的应用与 喵福禄 之间的数据交互是安全的重要手段。它能够:

  1. 确保请求的真实性: 验证请求确实来自 喵福禄 ,而非恶意第三方。
  2. 保证数据完整性: 确保数据在传输过程中未被篡改。
  3. 防止重放攻击: 通过时间戳等机制,防止请求被重复使用。

应用场景

Webhooks通知

当您配置的 Webhooks 事件触发时, 喵福禄 会向您的配置的 Webhook 地址推送 HTTP POST 请求。在推送这些数据时, 喵福禄 会对数据进行签名处理。 您收到请求后,可通过验证签名来验证数据的合法性,以确保请求的真实性和数据完整性。详细内容请参阅:Webhooks通知

签名算法

喵福禄 使用 HMAC-SHA256 算法对 HTTP 请求数据进行签名。这种验证机制广泛应用于 API 安全,有效的确保请求的完整性和真实性。

HMAC-SHA256是一种结合了哈希消息认证码(HMAC)和 SHA-256 哈希函数的安全算法,它是基于散列函数的消息认证码,由以下两个算法组成:

获取 App Secret

在 喵福禄 的管理应用,点击 创建应用 获取 App IDApp Secret

如果您已经创建了应用,忘记了 App Secret,那么您需要点击 重置密钥 按钮以获取新的 App Secret 。 更多详情请参阅:快速开始

注意:如果您的应用更新了 App Secret ,您应该使用最新的 App Secret 进行签名。

数据加签与验签

Header 签名参数:

Header 签名参数适用于 喵福禄 支持的所有请求方式,用于签名验证。当然,如果您正在使用 Query 请求,您也可以将这些参数放在 Query 参数中,更多详情请参阅:Query 签名参数

  • X-Meowflow-Timestamp: 时间戳(13位,毫秒)
  • X-Meowflow-Signature: 签名
Query 签名参数:

Query 请求是指使用 GET 或 DELETE 方法发送的请求

Query 参数仅在 Query 请求中使用,用于签名验证。当然,您也可以将这些参数放在 Header 中,更多详细内容请参阅:Header 签名参数

  • meowflow_timestamp: 时间戳(13位,毫秒)
  • meowflow_signature: 签名

温馨提示:

  • 当涉及 Query 参数加签数据时,您可以根据自己的需求选择将参数放在 Header 中还是 Query 中。 如果您同时在 Header 和 Query 中传递了相同的参数,那么 喵福禄 会优先使用 Query 中的参数。
  • 在签名验证时,您需要优先从 Query 参数中检出 Query 签名参数 进行验证。 如果 Query 参数中没有签名相关参数,那么您需要尝试使用 Header 签名参数 进行验证。
Query 请求

Query 请求是指使用 GET 或 DELETE 方法发送的请求。

Query 签名流程

签名内容格式: {HTTPMethod} {Domain}{Path}?{sortedQueryString}

  1. 构造待签名字符串
    1. Method: HTTP 请求方法
    2. 空格: 分隔 Method 和 Domain
    3. Domain: HTTP 请求 Domain (Host + Port(非 80/443))
    4. Path: HTTP 请求路径
    5. 如果 Query 参数包含 meowflow_signature,则需要将其排除在签名计算之外
    6. Query 参数需要包含 meowflow_timestamp 参数,且该参数的值与 X-Meowflow-Timestamp 的值相同
    7. 按照字典序排序的 Query 参数的 Key
    8. Query 参数的 Key 和 Value 之间使用 = 连接,多个 Value 之间使用 , 连接
    9. 多个 Query 参数之间使用 & 连接
    10. 使用 ? + Query 参数字符串拼接为待签名字符串
  2. 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
Query signature

上述流程图中 source 为GET example.com/api?a=1&b=d&c=a&meowflow_timestamp=1693497601234&z=abc

发送 Query 请求

在需要加签的应用场景下,如果您尝试向 喵福禄 发送 Query 请求时,您需要在请求中包含签名相关的参数。您可以选择将参数放在 Header 中或 Query 中。

使用 Header 参数发送请求

当您发送请求时,您需要在请求头中包含 Header 签名参数

您可以使用以下示例代码来发送请求:

请求
1curl -X GET "https://example.com/api?a=1&b=d&c=a&z=abc" \
2-H "X-Meowflow-Timestamp: <TIMESTAMP_MILLI>" \
3-H "X-Meowflow-Signature: <SIGNATURE>"
使用 Query 参数发送请求

当您发送请求时,您需要在请求 URL 中包含 Query 签名参数

您可以使用以下示例代码来发送请求:

请求
1curl -X GET "https://example.com/api?b=d&c=a&a=1&meowflow_timestamp=<TIMESTAMP_MILLI>&z=abc&meowflow_signature=<SIGNATURE>"

接收 Query 请求

在需要验签的应用场景下,当 喵福禄 向您的应用程序推送数据时,您需要验证签名以确保请求的真实性和数据完整性。

  1. 请您参阅 Query 签名流程 对请求参数进行处理并得到待签名字符串
  2. 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
  3. 将签名结果与原请求中的签名进行比对,如果一致,则请求合法,否则认为请求不合法

Body 请求

Body 请求是指使用 POST、PUT 或 PATCH 方法发送的请求

Body 签名流程

签名内容格式: {HTTPMethod} {Domain}{Path} {Body}{Timestamp}

  1. 构造待签名字符串
    1. Method: HTTP 请求方法
    2. 空格: 分隔 Method 和 Domain
    3. Domain: HTTP 请求 Domain (Host + Port(非 80/443))
    4. Path: HTTP 请求路径
    5. " " + 请求体的原始内容 + timestamp 拼接为待签名字符串
  2. 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
Body signature

上述流程图中 source 为POST example.com/api {\"b\":\"d\",\"c\":\"a\",\"a\":1}1693497601234

发送 Body 请求

在需要加签的应用场景 下,如果您尝试向 喵福禄 发送 Body 请求时,您需要在请求头中包含 Header 签名参数

您可以使用以下示例代码来发送请求:

1curl -X POST "https://example.com/api" \
2   -H "Content-Type: application/json" \
3   -H "X-Meowflow-Timestamp: <TIMESTAMP_MILLI>" \
4   -H "X-Meowflow-Signature: <SIGNATURE>" \
5   -d '{"b":1,"a":"d","z":"d"}'
接收 Body 请求

在需要验签的应用场景下,当 喵福禄 向您的应用程序推送数据时,您需要验证签名以确保请求的真实性和数据完整性。

  1. 请您参阅 Body 签名流程 对请求参数进行处理并得到待签名字符串
  2. 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
  3. 将签名结果与原请求中的签名进行比对,如果一致,则请求合法,否则认为请求不合法

温馨提示:

  • 喵福禄 的签名时间戳有效期为 5 分钟,即请求时间戳与当前时间戳的差值不超过 5 分钟。
  • 您应该验证请求的时间戳,以防止重放攻击。您可以在签名验证时检查时间戳是否在有效期内。如果时间戳不在有效期内,则拒绝该请求。
  • 当您向 喵福禄 发送请求时,您的时间戳应该与 喵福禄 的时间戳相差不超过 5 分钟。否则,请求将被拒绝。

签名与验签示例代码

为了能让您更好地理解数据签名与验证的过程,我们提供了不同语言和框架的示例代码。但是这些示例代码并不是生产环境的最佳实践,您需要根据自己的实际情况进行调整。

注意:

为了您的数据安全,请不要直接将以下代码用于生产环境,以免造成不必要的损失。

Java

1. 签名算法
1package com.example.springboot;
2
3import org.apache.commons.codec.digest.HmacAlgorithms;
4import org.apache.commons.codec.digest.HmacUtils;
5
6public class SignatureUtils {
7    public static String sign(String secret, String contents) {
8        return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret).hmacHex(contents);
9    }
10}
2. 验证签名示例,代码基于 Spring Boot 框架
1package com.example.springboot.controller;
2
3import org.apache.commons.codec.digest.HmacAlgorithms;
4import org.apache.commons.codec.digest.HmacUtils;
5import org.slf4j.Logger;
6import org.slf4j.LoggerFactory;
7import org.springframework.http.HttpStatus;
8import org.springframework.web.bind.annotation.*;
9import org.springframework.web.server.ResponseStatusException;
10
11import jakarta.servlet.http.HttpServletRequest;
12
13import java.io.IOException;
14import java.util.*;
15import java.util.stream.Collectors;
16
17@RestController
18public class WebhookController {
19
20    private static final Logger logger = LoggerFactory.getLogger(WebhookController.class);
21
22    private static final String HEADER_TIMESTAMP_KEY = "X-Meowflow-Timestamp";
23    private static final String HEADER_SIGNATURE_KEY = "X-Meowflow-Signature";
24    private static final String QUERY_KEY_TIMESTAMP = "meowflow_timestamp";
25    private static final String QUERY_KEY_SIGNATURE = "meowflow_signature";
26    private static final long TIMESTAMP_EXPIRED_TIME = 300000; // 5 minutes
27
28    /**
29     * TODO:: need to replace with your app secret key from your Meowflow app
30     */
31    private final String secret = "your app secret";
32
33    @RequestMapping(value = "/api/webhook", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, RequestMethod.DELETE})
34    public Map<String, String> handle(HttpServletRequest request) {
35        try {
36            verifySignature(request);
37        } catch (VerifySignatureException e) {
38            throw new ResponseStatusException(HttpStatus.FORBIDDEN, e.getMessage());
39        } catch (Exception e) {
40            logger.error("Unknown error: {}", e.getMessage(), e);
41            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error");
42        }
43
44        //  TODO:: do something with the request here...
45        logger.info("----------- received signature request -----------");
46        return Map.of("message", "OK");
47    }
48
49    private void verifySignature(HttpServletRequest request) throws VerifySignatureException, IOException {
50        Map<String, String> signatureFields = getSignatureFields(request);
51        String timestamp = signatureFields.get(QUERY_KEY_TIMESTAMP);
52        String signature = signatureFields.get(QUERY_KEY_SIGNATURE);
53        checkSignatureBaseFields(timestamp, signature);
54
55        String contents = buildSignatureContents(request, timestamp);
56        if (!compareSignature(signature, contents)) {
57            throw new VerifySignatureException("Signature verification failed");
58        }
59    }
60
61    private Map<String, String> getSignatureFields(HttpServletRequest request) {
62        String timestamp;
63        String signature;
64
65        if (isUrlDigest(request.getMethod())) {
66            timestamp = Optional.ofNullable(request.getParameter(QUERY_KEY_TIMESTAMP))
67                    .orElse(request.getHeader(HEADER_TIMESTAMP_KEY));
68            signature = Optional.ofNullable(request.getParameter(QUERY_KEY_SIGNATURE))
69                    .orElse(request.getHeader(HEADER_SIGNATURE_KEY));
70        } else {
71            timestamp = request.getHeader(HEADER_TIMESTAMP_KEY);
72            signature = request.getHeader(HEADER_SIGNATURE_KEY);
73        }
74
75        return Map.of(
76                QUERY_KEY_TIMESTAMP, Optional.ofNullable(timestamp).orElse(""),
77                QUERY_KEY_SIGNATURE, Optional.ofNullable(signature).orElse("")
78        );
79    }
80
81    private String buildSignatureContents(HttpServletRequest request, String timestamp) throws IOException {
82        StringBuilder contents = new StringBuilder(request.getMethod())
83                .append(" ")
84            .append(Optional.ofNullable(request.getServerName())
85                    .map(name -> {
86                        int port = request.getServerPort();
87                        boolean isNonStandardPort = (port != 80 && port != 443) ||
88                                (port == 80 && request.isSecure()) ||
89                                (port == 443 && !request.isSecure());
90                        return isNonStandardPort ? name + ":" + port : name;
91                    })
92                    .orElse(""))
93            .append(request.getRequestURI());
94    if (isUrlDigest(request.getMethod())) {
95        String queryParams = buildUrlSignatureContent(request, timestamp);
96        contents.append("?").append(queryParams);
97    } else {
98        contents.append(" ");
99        if (request.getContentLength() > 0) {
100            contents.append(request.getReader().lines().collect(Collectors.joining()));
101        }
102        contents.append(timestamp);
103    }
104    return contents.toString();
105}
106
107    private String buildUrlSignatureContent(HttpServletRequest request, String timestamp) {
108        Map<String, List<String>> parameterMap = new TreeMap<>();
109
110        request.getParameterMap().forEach((key, values) -> {
111            if (!QUERY_KEY_SIGNATURE.equals(key)) {
112                parameterMap.put(key, Arrays.asList(values));
113            }
114        });
115
116        if (!parameterMap.containsKey(QUERY_KEY_TIMESTAMP)) {
117            parameterMap.put(QUERY_KEY_TIMESTAMP, Collections.singletonList(timestamp));
118        }
119
120        return parameterMap.entrySet().stream()
121                .flatMap(entry -> entry.getValue().stream()
122                        .map(value -> entry.getKey() + "=" + value))
123                .sorted()
124                .collect(Collectors.joining("&"));
125    }
126
127    private void checkSignatureBaseFields(String timestamp, String signature) throws VerifySignatureException {
128        if (timestamp.isEmpty()) {
129            throw new VerifySignatureException("Missing timestamp");
130        }
131        if (signature.isEmpty()) {
132            throw new VerifySignatureException("Missing signature");
133        }
134        if (!isTimestampValid(timestamp)) {
135            throw new VerifySignatureException("Timestamp is invalid");
136        }
137    }
138
139    private boolean isTimestampValid(String timestamp) {
140        long timestampMilli = Long.parseLong(timestamp);
141        long currentTimeMilli = System.currentTimeMillis();
142        return timestampMilli > 0 && (currentTimeMilli - timestampMilli) <= TIMESTAMP_EXPIRED_TIME;
143    }
144
145    private boolean isUrlDigest(String method) {
146        return method.isEmpty() || method.equals("GET") || method.equals("DELETE");
147    }
148
149    private boolean compareSignature(String signature, String contents) {
150        String calculatedSignature = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret).hmacHex(contents);
151
152        // log for debug
153        logger.warn("Signature: contents={}, signature={}, hash={}, secret={}",
154                contents, signature, calculatedSignature, secret);
155
156        return calculatedSignature.equals(signature);
157    }
158
159    private static class VerifySignatureException extends Exception {
160        public VerifySignatureException(String message) {
161            super(message);
162        }
163    }
164}

PHP

1. 签名算法
1<?php
2declare(strict_types=1);
3
4function sign(string $secret, string $contents): string {
5    return hash_hmac('sha256', $contents, $secret);
6}
2. 验证签名示例,代码基于 Laravel 框架,当然它也适用于 Symfony 框架
1<?php
2declare(strict_types=1);
3
4namespace App\Http\Controllers;
5
6use Exception;
7use Illuminate\Http\JsonResponse;
8use Illuminate\Http\Request;
9use Illuminate\Support\Facades\Log;
10use Symfony\Component\HttpFoundation\InputBag;
11
12class VerifySignatureException extends Exception
13{
14}
15
16class WebhookController extends Controller
17{
18    private const HEADER_TIMESTAMP_KEY = 'X-Meowflow-Timestamp';
19    private const HEADER_SIGNATURE_KEY = 'X-Meowflow-Signature';
20    private const QUERY_KEY_TIMESTAMP = 'meowflow_timestamp';
21    private const QUERY_KEY_SIGNATURE = 'meowflow_signature';
22    private const TIMESTAMP_EXPIRED_TIME = 300000; // 5 minutes
23
24    /**
25     * TODO:: need to replace with your app secret key from your Meowflow app
26     *
27     * @var string your app secret
28     */
29    private string $secret = 'your app secret';
30
31    public function handle(Request $request): JsonResponse
32    {
33        try {
34            $this->verifySignature($request);
35        } catch (VerifySignatureException $e) {
36            return response()->json(['message' => $e->getMessage()], 403);
37        } catch (Exception $e) {
38            Log::error('Unknown error: ' . $e->getMessage(), ['trace' => $e->getTraceAsString()]);
39            return response()->json(['message' => 'Internal server error'], 500);
40        }
41
42        // TODO:: do something with the request here...
43        Log::info('----------- received signature request -----------');
44        return response()->json(['message' => 'OK']);
45    }
46
47    /**
48     * @throws VerifySignatureException
49     */
50    private function verifySignature(Request $request): void
51    {
52        [$timestamp, $signature] = $this->getSignatureFields($request);
53        $this->checkSignatureBaseFields($timestamp, $signature);
54
55        $contents = $this->buildSignatureContents($request, $timestamp);
56        if (!$this->compareSignature($signature, $contents)) {
57            throw new VerifySignatureException('Signature verification failed');
58        }
59    }
60
61    /**
62     * @param Request $request
63     * @return array<string>
64     */
65    private function getSignatureFields(Request $request): array
66    {
67        if ($this->isUrlDigest($request->getMethod())) {
68            $timestamp = $request->query->getString(self::QUERY_KEY_TIMESTAMP, $request->headers->get(self::HEADER_TIMESTAMP_KEY));
69            $signature = $request->query->getString(self::QUERY_KEY_SIGNATURE, $request->headers->get(self::HEADER_SIGNATURE_KEY));
70        } else {
71            $timestamp = $request->headers->get(self::HEADER_TIMESTAMP_KEY);
72            $signature = $request->headers->get(self::HEADER_SIGNATURE_KEY);
73        }
74
75        return [
76            $timestamp ?? '',
77            $signature ?? ''
78        ];
79    }
80
81    private function buildSignatureContents(Request $request, string $timestamp): string
82    {
83        $method = $request->getMethod();
84        $contents = $method . ' ' . $request->getHttpHost() . $request->getPathInfo();
85
86        if ($this->isUrlDigest($method)) {
87            $contents .= '?' . $this->buildUrlSignatureContent($request->query, $timestamp);
88        } else {
89            $contents .= ' ' . $request->getContent() . $timestamp;
90        }
91
92        return $contents;
93    }
94
95    private function buildUrlSignatureContent(InputBag $query, string $timestamp): string
96    {
97        $params = [];
98        foreach ($query as $key => $value) {
99            if ($key !== self::QUERY_KEY_SIGNATURE) {
100                $params[$key] = is_array($value) ? implode(',', $value) : (string)$value;
101            }
102        }
103        $params[self::QUERY_KEY_TIMESTAMP] ??= $timestamp;
104
105        ksort($params);
106        return http_build_query($params);
107    }
108
109    private function checkSignatureBaseFields(string $timestamp, string $signature): void
110    {
111        if (empty($timestamp)) {
112            throw new VerifySignatureException('Missing timestamp');
113        }
114        if (empty($signature)) {
115            throw new VerifySignatureException('Missing signature');
116        }
117        if (!$this->isTimestampValid($timestamp)) {
118            throw new VerifySignatureException('Timestamp is invalid');
119        }
120    }
121
122    private function isTimestampValid(string $timestamp): bool
123    {
124        $timestampMilli = (int)$timestamp;
125        $currentTimeMilli = (int)(microtime(true) * 1000);
126        return $timestampMilli > 0 && ($currentTimeMilli - $timestampMilli) <= self::TIMESTAMP_EXPIRED_TIME;
127    }
128
129    private function isUrlDigest(string $method): bool
130    {
131        return empty($method) || $method === 'GET' || $method === 'DELETE';
132    }
133
134    private function compareSignature(string $signature, string $contents): bool
135    {
136        $calculatedSignature = hash_hmac('sha256', $contents, $this->secret);
137
138        // log for debug
139        Log::warning('Signature: ', [
140            'contents' => $contents,
141            'signature' => $signature,
142            'hash' => $calculatedSignature,
143            'secret' => $this->secret
144        ]);
145
146        return hash_equals($calculatedSignature, $signature);
147    }
148}

Python

1. 签名算法
1import hmac
2import hashlib
3
4def sign(secret: str, contents: str) -> str:
5    return hmac.new(secret.encode(), contents.encode(), hashlib.sha256).hexdigest()
2. 验证签名示例,代码基于 Flask 框架
1import hmac
2import hashlib
3import time
4
5from flask import Flask, request, jsonify
6from typing import Tuple
7
8app = Flask(__name__)
9
10
11class VerifySignatureException(Exception):
12    pass
13
14
15class WebhookController:
16    HEADER_TIMESTAMP_KEY = 'X-Meowflow-Timestamp'
17    HEADER_SIGNATURE_KEY = 'X-Meowflow-Signature'
18    QUERY_KEY_TIMESTAMP = 'meowflow_timestamp'
19    QUERY_KEY_SIGNATURE = 'meowflow_signature'
20    TIMESTAMP_EXPIRED_TIME = 300000  # 5 minutes
21
22    def __init__(self):
23        # TODO:: need to replace with your app secret key from your Meowflow app
24        self.secret = 'your app secret'
25
26    @staticmethod
27    @app.route('/api/webhook', methods=['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
28    def handle():
29        controller = WebhookController()
30        try:
31            controller.verify_signature()
32        except VerifySignatureException as e:
33            return jsonify({'message': str(e)}), 403
34        except Exception as e:
35            app.logger.error(f'Unknown error: {str(e)}')
36            return jsonify({'message': 'Internal server error'}), 500
37
38        # TODO:: do something with the request here...
39        app.logger.warning('----------- received signature request -----------')
40        return jsonify({'message': 'OK'})
41
42    def verify_signature(self) -> None:
43        timestamp, signature = self.get_signature_fields()
44        self.check_signature_base_fields(timestamp, signature)
45        contents = self.build_signature_contents(timestamp)
46        if not self.compare_signature(signature, contents):
47            raise VerifySignatureException('Signature verification failed')
48
49    def get_signature_fields(self) -> Tuple[str, str]:
50        if self.is_url_digest(request.method):
51            timestamp = request.args.get(self.QUERY_KEY_TIMESTAMP) or request.headers.get(self.HEADER_TIMESTAMP_KEY, '')
52            signature = request.args.get(self.QUERY_KEY_SIGNATURE) or request.headers.get(self.HEADER_SIGNATURE_KEY, '')
53        else:
54            timestamp = request.headers.get(self.HEADER_TIMESTAMP_KEY, '')
55            signature = request.headers.get(self.HEADER_SIGNATURE_KEY, '')
56        return timestamp, signature
57
58    def build_signature_contents(self, timestamp: str) -> str:
59        method = request.method
60        contents = f"{method} {request.host}{request.path}"
61        if self.is_url_digest(method):
62            query_params = self.build_url_signature_content(timestamp)
63            contents += f"?{query_params}"
64        else:
65            contents += " "
66            if request.data:
67                contents += f"{request.data.decode('utf-8')}"
68            contents += f" {timestamp}"
69        return contents
70
71    def build_url_signature_content(self, timestamp: str) -> str:
72        params = {k: v for k, v in request.args.lists() if k != self.QUERY_KEY_SIGNATURE}
73        if self.QUERY_KEY_TIMESTAMP not in params:
74            params[self.QUERY_KEY_TIMESTAMP] = [timestamp]
75        params = {k: ','.join(v) for k, v in params.items()}
76
77        return '&'.join(f"{k}={v}" for k, v in sorted(params.items()))
78
79    def check_signature_base_fields(self, timestamp: str, signature: str) -> None:
80        if not timestamp:
81            raise VerifySignatureException('Missing timestamp')
82        if not signature:
83            raise VerifySignatureException('Missing signature')
84        if not self.is_timestamp_valid(timestamp):
85            raise VerifySignatureException('Timestamp is invalid')
86
87    def is_timestamp_valid(self, timestamp: str) -> bool:
88        timestamp_milli = int(timestamp)
89        current_time_milli = int(time.time() * 1000)
90        return timestamp_milli > 0 and (current_time_milli - timestamp_milli) <= self.TIMESTAMP_EXPIRED_TIME
91
92    @staticmethod
93    def is_url_digest(method: str) -> bool:
94        return not method or method in ['GET', 'DELETE']
95
96    def compare_signature(self, signature: str, contents: str) -> bool:
97        expected_signature = hmac.new(self.secret.encode(), contents.encode(), hashlib.sha256).hexdigest()
98
99        # log for debug
100        app.logger.warning('Signature: contents=%s, signature=%s, hash=%s, secret=%s',
101                           contents, signature, expected_signature, self.secret
102                           )
103
104        return hmac.compare_digest(expected_signature, signature)
105
106
107if __name__ == '__main__':
108    app.run(debug=True)

Go

1. 签名算法
1package main
2
3import (
4	"crypto/hmac"
5	"crypto/sha256"
6	"encoding/hex"
7 )
8
9func Sign(secret, contents string) string {
10    h := hmac.New(sha256.New, []byte(secret))
11    h.Write([]byte(contents))
12    return hex.EncodeToString(h.Sum(nil))
13}
2. 验证签名示例,代码基于标准库 net/http 实现
1package main
2
3import (
4    "bytes"
5    "crypto/hmac"
6    "crypto/sha256"
7    "encoding/hex"
8    "errors"
9    "fmt"
10    "io"
11    "log"
12    "log/slog"
13    "net/http"
14    "net/url"
15    "slices"
16    "strconv"
17    "strings"
18    "time"
19)
20
21const (
22    headerTimestampKey   = "X-Meowflow-Timestamp"
23    headerSignatureKey   = "X-Meowflow-Signature"
24    queryKeyTimestamp    = "meowflow_timestamp"
25    queryKeySignature    = "meowflow_signature"
26    timestampExpiredTime = 300000 // 5 minutes
27)
28
29// TODO:: need to replace with your app secret key from your Meowflow app
30var secret = "your app secret"
31
32type verifySignatureError struct {
33    message string
34}
35
36func (e *verifySignatureError) Error() string {
37    if e == nil {
38        return ""
39    }
40    return e.message
41}
42
43func handleWebhook(w http.ResponseWriter, r *http.Request) {
44    if err := verifySignature(r); err != nil {
45        var (
46            statusCode int
47            message    string
48        )
49        if se := new(verifySignatureError); errors.As(err, &se) {
50            message = se.message
51            statusCode = http.StatusForbidden
52        } else {
53            message = "Internal Server Error"
54            statusCode = http.StatusInternalServerError
55            slog.Error(fmt.Sprintf("Unknown error: %+v", err))
56        }
57        responseJson(w, statusCode, message)
58        return
59    }
60
61    // TODO:: do something with the request here...
62    slog.Info("----------- received signature request -----------")
63    responseJson(w, http.StatusOK, "OK")
64}
65
66func responseJson(w http.ResponseWriter, statusCode int, message string) {
67    w.Header().Set("Content-Type", "application/json; charset=utf-8")
68    w.WriteHeader(statusCode)
69    w.Write([]byte(fmt.Sprintf(`{"message":"%s"}`, message)))
70}
71
72func verifySignature(r *http.Request) error {
73    timestamp, signature := getSignatureFields(r)
74    if err := checkSignatureBaseFields(timestamp, signature); err != nil {
75        return err
76    }
77
78    contents := buildSignatureContents(r, timestamp)
79    if !compareSignature([]byte(signature), contents) {
80        return &verifySignatureError{message: "signature verification failed"}
81    }
82    return nil
83}
84
85func getSignatureFields(r *http.Request) (timestamp string, signature string) {
86    if isURLDigest(r.Method) {
87        timestamp = r.URL.Query().Get(queryKeyTimestamp)
88        if timestamp == "" {
89            timestamp = r.Header.Get(headerTimestampKey)
90        }
91        signature = r.URL.Query().Get(queryKeySignature)
92        if signature == "" {
93            signature = r.Header.Get(headerSignatureKey)
94        }
95        return timestamp, signature
96    }
97    return r.Header.Get(headerTimestampKey), r.Header.Get(headerSignatureKey)
98}
99
100func buildSignatureContents(r *http.Request, timestamp string) []byte {
101    var buf bytes.Buffer
102    buf.WriteString(r.Method)
103    buf.WriteString(" ")
104    if r.URL.Host != "" {
105        buf.WriteString(r.URL.Host)
106    } else {
107        buf.WriteString(r.Host)
108    }
109    path := r.URL.Path
110    if path == "" {
111        path = "/"
112    } else if !strings.HasPrefix(path, "/") {
113        path = "/" + path
114    }
115    buf.WriteString(path)
116
117    if isURLDigest(r.Method) {
118        b := buildURLSignatureContent(r.URL.Query(), timestamp)
119        buf.WriteString("?")
120        buf.Write(b)
121    } else {
122        bodyBytes, _ := io.ReadAll(r.Body)
123        r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // reset body
124        buf.WriteString(" ")
125        buf.Write(bodyBytes)
126        buf.WriteString(timestamp)
127    }
128    return buf.Bytes()
129}
130
131func buildURLSignatureContent(query url.Values, timestamp string) []byte {
132    params := make(url.Values)
133    for key, values := range query {
134        if key != queryKeySignature {
135            params[key] = values
136        }
137    }
138
139    if !params.Has(queryKeyTimestamp) {
140        params.Set(queryKeyTimestamp, timestamp)
141    }
142
143    keys := make([]string, 0, len(params))
144    for k := range params {
145        keys = append(keys, k)
146    }
147    slices.Sort(keys)
148    var buf bytes.Buffer
149    for _, k := range keys {
150        buf.WriteString(k)
151        buf.WriteString("=")
152        buf.WriteString(strings.Join(params[k], ","))
153        buf.WriteString("&")
154    }
155    if buf.Len() > 0 {
156        buf.Truncate(buf.Len() - 1) // remove last "&"
157    }
158    return buf.Bytes()
159}
160
161func checkSignatureBaseFields(timestamp, signature string) error {
162    if timestamp == "" {
163        return &verifySignatureError{message: "missing timestamp"}
164    }
165    if signature == "" {
166        return &verifySignatureError{message: "missing signature"}
167    }
168    if !isTimestampValid(timestamp) {
169        return &verifySignatureError{message: "timestamp is invalid"}
170    }
171    return nil
172}
173
174func isTimestampValid(timestamp string) bool {
175    timestampMilli, err := strconv.ParseInt(timestamp, 10, 64)
176    if err != nil {
177        return false
178    }
179    currentTimeMilli := time.Now().UnixMilli()
180    return timestampMilli > 0 && (currentTimeMilli-timestampMilli) <= timestampExpiredTime
181}
182
183func isURLDigest(method string) bool {
184    return method == "" || method == http.MethodGet || method == http.MethodDelete
185}
186
187func compareSignature(signature, contents []byte) bool {
188    h := hmac.New(sha256.New, []byte(secret))
189    h.Write(contents)
190    calculatedSignature := hex.EncodeToString(h.Sum(nil))
191
192    // log for debug
193    slog.Warn(fmt.Sprintf("Signature: contents=%s, signature=%s, hash=%s, secret=%s",
194        contents, signature, calculatedSignature, secret))
195
196    return hmac.Equal([]byte(calculatedSignature), signature)
197}
198
199func main() {
200    http.HandleFunc("/api/webhook", handleWebhook)
201    slog.Info("Server is running on http://127.0.0.1:8080")
202    log.Fatalln(http.ListenAndServe(":8080", nil))
203}

JavaScript / TypeScript

1. 签名算法, require CommonJS modules
1const crypto = require('crypto');
2
3function sign(secret, contents) {
4  return crypto.createHmac('sha256', secret).update(contents).digest('hex');
5}
import ES6 modules
1import {createHmac} from 'crypto';
2
3export function sign(secret: string, contents: string): string {
4  return createHmac('sha256', secret).update(contents).digest('hex');
5}
2. 验证签名示例,代码基于 Express 框架
1import {Request, Response} from 'express';
2import crypto from 'crypto';
3import {ParsedQs} from "qs";
4
5class VerifySignatureError extends Error {
6    constructor(message: string) {
7        super(message);
8        this.name = 'VerifySignatureError';
9    }
10}
11
12export class WebhookController {
13    private static readonly HEADER_TIMESTAMP_KEY = 'X-Meowflow-Timestamp';
14    private static readonly HEADER_SIGNATURE_KEY = 'X-Meowflow-Signature';
15    private static readonly QUERY_KEY_TIMESTAMP = 'meowflow_timestamp';
16    private static readonly QUERY_KEY_SIGNATURE = 'meowflow_signature';
17    private static readonly TIMESTAMP_EXPIRED_TIME = 300000; // 5 minutes
18
19
20    /**
21     * TODO:: need to replace with your app secret key from your Meowflow app
22     *
23     * @var string your app secret
24     */
25    private readonly secret: string = 'your app secret';
26
27
28    public async handle(req: Request, res: Response): Promise<Response> {
29        try {
30            await this.verifySignature(req);
31        } catch (e) {
32            if (e instanceof VerifySignatureError) {
33                return res.status(403).json({message: e.message});
34            }
35            console.error('Unknown error: ', e);
36            return res.status(500).json({message: 'Internal server error'});
37        }
38
39        // TODO:: do something with the request here...
40        console.log('----------- received signature request -----------');
41        return res.json({message: 'OK'});
42    }
43
44    private async verifySignature(req: Request): Promise<void> {
45        const {timestamp, signature} = this.getSignatureFields(req);
46        this.checkSignatureBaseFields(timestamp, signature);
47
48        const contents = this.buildSignatureContents(req, timestamp);
49        if (!this.compareSignature(signature, contents)) {
50            throw new VerifySignatureError("Signature verification failed");
51        }
52    }
53
54    private getSignatureFields(req: Request): { timestamp: string; signature: string } {
55        let timestamp: string | undefined;
56        let signature: string | undefined;
57
58        if (this.isUrlDigest(req.method)) {
59            timestamp = (req.query[WebhookController.QUERY_KEY_TIMESTAMP] as string) || req.header(WebhookController.HEADER_TIMESTAMP_KEY);
60            signature = (req.query[WebhookController.QUERY_KEY_SIGNATURE] as string) || req.header(WebhookController.HEADER_SIGNATURE_KEY);
61        } else {
62            timestamp = req.header(WebhookController.HEADER_TIMESTAMP_KEY);
63            signature = req.header(WebhookController.HEADER_SIGNATURE_KEY);
64        }
65
66        return {
67            timestamp: timestamp || '',
68            signature: signature || ''
69        };
70    }
71
72    private buildSignatureContents(req: Request, timestamp: string): string {
73        const {method, path, query} = req;
74        const host = req.header('host') || '';
75        let contents = `${method} ${host}${path}`;
76
77        if (this.isUrlDigest(method)) {
78            const queryParams = this.buildUrlSignatureContent(query, timestamp);
79            contents += `?${queryParams}`;
80        } else {
81            const rawBody = (req as any).rawBody;
82            if (rawBody) {
83                contents += ` ${rawBody.toString()}${timestamp}`;
84            } else {
85                contents += ` ${timestamp}`;
86            }
87        }
88
89        return contents;
90    }
91
92    private buildUrlSignatureContent(query: ParsedQs, timestamp: string): string {
93        const params: Record<string, string> = Object.fromEntries(
94            Object.entries(query)
95                .filter(([key]) => key !== WebhookController.QUERY_KEY_SIGNATURE)
96                .map(([key, value]) => [
97                    key,
98                    Array.isArray(value) ? value.join(',') : String(value)
99                ])
100        );
101
102        params[WebhookController.QUERY_KEY_TIMESTAMP] ??= timestamp;
103
104        const sortedParams = Object.fromEntries(
105            Object.entries(params).sort(([a], [b]) => a.localeCompare(b))
106        );
107
108        return new URLSearchParams(sortedParams).toString();
109    }
110
111    private checkSignatureBaseFields(timestamp: string, signature: string): void {
112        if (!timestamp) {
113            throw new VerifySignatureError("Missing timestamp");
114        }
115        if (!signature) {
116            throw new VerifySignatureError("Missing signature");
117        }
118        if (!this.isTimestampValid(timestamp)) {
119            throw new VerifySignatureError("Timestamp is invalid");
120        }
121    }
122
123    private isTimestampValid(timestamp: string): boolean {
124        const timestampMilli = parseInt(timestamp, 10);
125        return !isNaN(timestampMilli) && timestampMilli > 0 &&
126            (Date.now() - timestampMilli) <= WebhookController.TIMESTAMP_EXPIRED_TIME;
127    }
128
129    private isUrlDigest(method?: string): boolean {
130        return !method || method === 'GET' || method === 'DELETE';
131    }
132
133    private compareSignature(signature: string, contents: string): boolean {
134        const expectedSignature = crypto.createHmac('sha256', this.secret).update(contents).digest('hex')
135
136        // log for debug
137        console.log('Signature: ', {
138            contents: contents,
139            signature: signature,
140            hash: expectedSignature,
141            secret: this.secret
142        });
143
144        return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
145    }
146}

在 Express 框架中,您可能需要使用 body-parser 中间件来获取请求体的原始内容。在您的 app.ts 中应该添加类似如下的代码:

body-parser
1import express from 'express';
2
3const app = express();
4 
5// Capture the original request body as a Buffer.
6app.use(express.raw({ type: '*/*' }));
7
8// Parse the request body as JSON.
9app.use(express.json());
10
11// Maybe you need to parse more body types here...
12
13// After the original request body is parsed, save it to req.rawBody.
14app.use((req, res, next) => {
15   if (req.body instanceof Buffer) {
16       (req as any).rawBody = req.body;
17   }
18   next();
19});

Webhooks 指南

Webhooks 是一种通过 HTTP POST 请求将事件通知推送到您的服务器的机制。当您配置的 Webhooks 事件触发时, 喵福禄 会向您的配置的 Webhook 地址推送 HTTP POST 请求。 它们可以被视为反向的 API 调用:不是您的系统调用 喵福禄 ,而是 喵福禄 回调您的服务器。这对于接收由您的系统外部触发的事件通知特别有用,例如:当工序任务处理完成后,可以通过 Webhooks 通知您。

配置 Webhooks

在您创建应用时,您可以在 喵福禄 的管理应用页面配置 Webhooks。关于管理应用的更多信息,请参阅 快速开始 文档。

每个应用最多可以配置 5 个 Webhooks 地址,您可以为每个特定的 URL 配置不同的事件类型。您可以在 Webhooks 事件类型 中查看支持的事件类型。

接收并处理 Webhooks 事件

当您订阅的 Webhooks 事件触发时, 喵福禄 会向您所配置的 Webhooks 地址发送一个 HTTP POST 请求。请求的内容是一个 JSON 对象,包含有关事件的详细信息。

所有 Webhooks 推送结构都是相同的,只是 payload 字段的内容不同。payload字段包含有关事件的详细信息,具体内容取决于事件的类型,可参阅 Webhooks 事件类型

以下是一个 Webhooks 请求的示例:
1{
2  "id": "123456",
3  "webhookId": "1234567",
4  "appId": "12345",
5  "event": "workflowTask.completed",
6  "objectId": "task_123456",
7  "payload": {
8    "task_id": "task_123456",
9    "status": "success",
10    "result": {
11      "output": "Hello, World!"
12    }
13  },
14  "pushTime": "2024-08-31T12:00:00Z"
15}

Webhooks 请求字段说明

字段类型说明
idstring推送记录ID
webhookIdstringWebhooks 的 ID
appIdstring应用 ID
eventstring事件类型
objectIdstring触发事件的对象的 ID
payloadobject事件详细信息,根据 event 不同,其数据结构也不相同
pushTimestring推送时间,格式为 yyyy-MM-dd'T'HH:mm:ss'Z'

您需要做以下几件事情:

  1. 接收请求:您的服务器需要有一个公开的 URL,可以接收 喵福禄 发送的 HTTP POST 请求。
  2. 验证请求:您需要验证 喵福禄 发送的请求,以确保它来自 喵福禄。
  3. 处理请求:您需要处理 喵福禄 发送的请求,以执行您的业务逻辑。
  4. 响应请求:您需要向 喵福禄 发送一个 HTTP 响应。响应的状态码应该是 2xx,以确认您已成功接收并处理了请求。

验证 Webhooks 请求

为了验证 喵福禄 发送的 Webhooks 请求,您需要使用签名验证机制。详细内容请参阅:数据加签与验签

处理 Webhooks 请求

当您接收到 喵福禄 发送的 Webhooks 请求时,您需要解析请求的 JSON 数据,以获取事件的详细信息。您可以根据事件的类型和数据执行相应的业务逻辑。

响应 Webhooks 请求

当您成功接收并处理了 喵福禄 发送的 Webhooks 请求后,您需要向 喵福禄 发送一个 HTTP 响应。响应的状态码应该是 2xx,以确认您已成功接收并处理了请求。如果您返回的状态码不是 2xx, 喵福禄 将认为请求失败,并会在一段时间后重试。 推送重试的时间间隔会逐渐增加,直到达到最大重试次数(默认为 10 次)。

Webhooks 事件类型

支持以下 Webhooks 事件类型:

  1. 工序流任务

工序流任务

事件名称是否可用描述相关关联方法
workflowTask.completedY任务处理完成(成功处理)获取工序流任务
workflowTask.completed

当工序流任务处理完成时,会触发 workflowTask.completed 事件。

Payload:
1{
2  "id": "0",
3  "createdAt": "2023-09-01T12:00:00.999+08:00",
4  "updatedAt": "2023-09-01T12:00:00.999+08:00",
5  "customId": "string",
6  "workflowId": "0",
7  "viewStatus": 1,
8  "reason": "string",
9  "startExecutionAt": "2023-09-01T12:00:00.999+08:00",
10  "completedAt": "2023-09-01T12:00:00.999+08:00",
11  "duration": 0,
12  "totalPrice": "string",
13  "currency": "string",
14  "processOutputs": {}
15}

管理应用

应用名称
创建应用获取 APP ID

API 服务服务状态

https://api.meowflow.com.cn

获取 API 访问令牌

POST/v1/authentication/token

请求数据

grant_type*string

授权类型必须是 client_credentials

示例:client_credentials

client_id*string

客户端 ID

示例:41518078235770880

client_secret*string

客户端密钥

示例:wqwr2wdq...

响应

200
OK
codenumber

响应状态码

示例:

0

messagestring

响应描述

示例:

OK

dataobject

业务数据

access_tokenstring

accessToken

示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

token_typestring

令牌类型

示例:

bearer

expires_ininteger

过期时间(秒)

示例:

3600

scopestring

权限范围

示例:

read write

app_idstring

应用ID

示例:

41518078235770880

请求
POST/v1/authentication/token
1curl -X POST "https://api.meowflow.com.cn/v1/authentication/token" \
2  -H "Content-Type: application/x-www-form-urlencoded" \
3  -d "grant_type=client_credentials&client_id=YOUR_APP_ID&client_secret=YOUR_APP_SECRET"
响应
1{
2"code": 0,
3"message": "操作成功",
4"data": {
5    "access_token": "string",
6    "token_type": "string",
7    "expires_in": 0,
8    "scope": "string",
9    "app_id": "0"
10  }
11}

创建工序流任务

POST/v1/workflow-task

请求

APP 身份鉴权

请将 Access Token 放入 Authorization 头部发送

示例:Authorization: Bearer ${Access Token}

请求数据

items*object[]

任务项, 数组长度最大限制 20

示例:[{ "workflowId": "0", "customId": "string", "data": { } }]

workflowId*string

工序流 ID

示例:5813548736546847

customIdstring

一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符

示例:53424155105734656

data*object

任务数据

示例:{}

响应

200
OK
codenumber

响应状态码

示例:

0

messagestring

响应描述

示例:

OK

dataobject

业务数据

groupIdstring

任务批次ID

示例:

41518078235770880

errorItemsobject[]

错误任务列表

workflowIdstring

工序流 ID

示例:

41518078235770880

customIdstring

一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符

示例:

41518078235770880

reasonstring

失败原因

示例:

reason

请求
POST/v1/workflow-task
1curl -X POST "https://api.meowflow.com.cn/v1/workflowTask" \
2  -H "Authorization: Bearer ACCESS_TOKEN" \
3  -H "Content-Type: application/json;charset=UTF-8" \
4  -d '{
5    "items": [
6      {
7        "workflowId": "string",
8        "customId": "string",
9        "data": {}
10      }
11    ]
12  }'
响应
1{
2  "code": 0,
3  "message": "操作成功",
4  "data": {
5    "groupId": "0",
6    "errorItems": [
7      {
8        workflowId: "0",
9        customId: "string",
10        reason: "string",
11      },
12    ],
13  },
14}

获取工序流任务

GET/v1/workflow-task?customId={customId}
获取工序流任务详情

请求

APP 身份鉴权

请将 Access Token 放入 Authorization 头部发送

示例:Authorization: Bearer ${Access Token}

请求参数

customIdstring

一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符

示例:53424155105734656

idstring

任务 ID

示例:41518078235770880

customId 和 id 至少需要一个

响应

200
OK
codenumber

响应状态码

示例:

0

messagestring

响应描述

示例:

OK

dataobject

业务数据

idstring

任务 ID

示例:

41518078235770880

createdAtstring

创建时间

示例:

2023-09-01T12:00:00.999+08:00

updatedAtstring

更新时间

示例:

2023-09-01T12:00:00.999+08:00

customIdstring

一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符

示例:

53424155105734656

workflowIdstring

工序流 ID

示例:

41518078235770880

viewStatusnumber

可视状态:0=无,1=系统异常,2=处理成功,3=处理失败,4=处理中

示例:

1

reasonstring

失败原因

示例:

reason

startExecutionAtstring

开始执行时间 (格式: RFC3339Milli)

示例:

2023-09-01T12:00:00.999+08:00

completedAtstring

完成时间(成功或失败) (格式: RFC3339Milli)

示例:

2023-09-01T12:00:00.999+08:00

durationnumber

执行总耗时(单位: ms)

示例:

1000

totalPricestring

总价格

示例:

100

currencystring

交易货币(ISO 4217)

示例:

USD

processOutputsobject

工序处理结果

示例:

{}

请求
GET/v1/workflow-task?customId={customId}
1curl --location --request GET 'https://api.meowflow.com.cn/v1/workflow-task?customId={customId}' -H "Authorization: Bearer ACCESS_TOKEN" 
响应
1{
2  "code": 0,
3  "message": "操作成功",
4  "data": {
5    "id": "0",
6    "createdAt": "2023-09-01T12:00:00.999+08:00",
7    "updatedAt": "2023-09-01T12:00:00.999+08:00",
8    "customId": "string",
9    "workflowId": "0",
10    "viewStatus": 1,
11    "reason": "string",
12    "startExecutionAt": "2023-09-01T12:00:00.999+08:00",
13    "completedAt": "2023-09-01T12:00:00.999+08:00",
14    "duration": 0,
15    "totalPrice": "string",
16    "currency": "string",
17    "processOutputs": {}
18  }
19}