快速开始
1. 获取 APP ID 和 APP Secret
喵福禄使用APP ID 和 APP Secret 来验证 API 调用信息:
- APP ID 用于识别您的应用程序
- APP Secret 用于验证您的应用,您需要使用它和 APP ID 来交换 Access Token,以便调用其它 API
您可以按照以下步骤获取 APP ID 和 APP Secret:
- 请登录您的管理后台账号,如果您还没有账号,请先注册一个新账号
- 进入 API 文档页面
- 点击管理应用按钮,创建一个新的应用
- 在创建应用页面中,请填写应用的名称,回调地址等信息,然后点击创建应用按钮
- 创建成功后,您就可以在应用中看到您的 APP ID 和 APP Secret,如下图所示:
温馨提示:
- APP Secret 只会显示一次,请您务必妥善保管
- 请不要将您的 APP Secret 泄露给他人,以免造成不必要的损失
- 如果您的 APP Secret 不慎泄露,请及时在 管理应用 中更改
- 如果您忘记了 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"
- YOUR_APP_ID:替换为您的 APP ID
- 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 }'
- ACCESS_TOKEN:替换为您的 Access Token
- 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"
- YOUR_APP_ID:替换为您的 APP ID
- 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 的 GET、POST、PUT、PATCH 或 DELETE 方法、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 文档。
如:分页可以使用 page 和 pageSize 参数,排序可以使用 orderBy 和 sortBy 参数,日期筛选可以使用 startTime 和 endTime 参数。
温馨提示:
- 并非所有 API 都支持这些参数,具体需要查看对应 API 支持字段。
- 日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00 或 2023-09-01T12:00:00Z。
- 所有的 URL 参数都应该使用 URL encoding 格式编码,以确保特殊字符正确传递。
字段 | 类型 | 注释 | 是否必填 | 默认值 | 备注 |
---|---|---|---|---|---|
startTime | string | 日期筛选 - 开始时间 | NO | "" | 日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00 |
endTime | string | 日期筛选 - 结束时间 | NO | "" | 日期格式: RFC3339 eg: 2023-09-01T12:00:00+08:00 |
orderBy | string | 排序字段 | NO | id | 具体查看对应 API 支持字段 |
sortBy | string | 排序方式 | NO | desc | 排序方式:asc=升序,desc=降序 |
page | int | 页码 | NO | 1 | 最小值:1,最大值:100 |
pageSize | int | 每页数量 | NO | 20 | 最小值: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。
字段 | 类型 | 注释 | 是否必填 | 备注 |
---|---|---|---|---|
code | int | 业务状态码 | YES | 参考:业务通用错误码 |
message | string | 业务状态码描述 | YES | |
data | array | 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 错误码
状态码 | 业务错误码 | 说明 | 排错建议 |
---|---|---|---|
400 | 100002 | invalid param | 请求参数缺失或者有误,更多错误信息请参阅请求返回的 message |
400 | 100004 | bad request 或 invalid param | 传参错误,请确保请求信息、请求数据格式等是正确的 |
401 | 110007 | 用户 Access Token 无效 | 未传递 API访问令牌 或 已过期,需要传递有效的访问令牌或重新生成访问令牌 |
401 | 110009 | Unauthorized | 用户可能携带认证信息 ,但认证失败 |
403 | 100003 | forbidden | 权限不足,拒绝访问,请确保当前的 Access Token 具有操作该资源的权限 |
404 | 100008 | resource not found | 资源未找到,请检查传递的 资源ID 是否正确,资源是否成功被创建 |
405 | 100005 | method not allowed | HTTP 方法不支持,检查是否使用错误的请求方式,如:API 要求 POST 却使用 GET |
409 | 100018 | resource conflict | 该 API 无法完成请求的操作,因为它与当前正在处理的另一个请求冲突。请稍后重试该请求。 |
429 | 100006 | too many requests | 应用被限流,稍后再试,适当减小请求频率 |
HTTP 5xx 错误码
状态码 | 业务错误码 | 说明 | 排错建议 |
---|---|---|---|
500 | 100000 | 通用错误 | 查看 message 或 联系开发者 |
500 | 100001 | internal service error | 内部错误,请稍后重试 或 联系开发者 |
500 | 100019 | internal service error | 内部错误,请稍后重试 或 联系开发者 |
503 | 100007 | service 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_id 为 BAD_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 错误,这时您可以尝试等待一段时间再次请求。
喵福禄的"喵喵币"系统:生动解释令牌桶算法
想象下,现在 喵福禄 化身为一家神奇的猫咖,我们使用"喵喵币"来管理访客流量。这就像令牌桶算法的工作方式:
喵喵币系统(令牌桶)运作规则
- 我们有一个"喵喵币储蓄罐",最多可存60枚喵喵币。(令牌桶最大容量)
- 每秒钟,我们会往储蓄罐里添加2枚新的喵喵币,直到达到上限。(令牌桶填充速率)
- 每位访客进入时需要借用一枚喵喵币。(API请求)
- 访客离开时,会归还喵喵币,但储蓄罐不会超过最大容量。(API请求完成)
- 如果储蓄罐里有喵喵币,访客可以立即进入;如果没有,需要等待。(请求速率限制:429 Too Many Requests)
一天中的喵咖时光
让我们看看在不同时段,猫咖是如何运作的:
- 早晨开业(桶满状态):
- 9:00,猫咖开门,储蓄罐里有60枚喵喵币。
- 来了20位访客,他们借走20枚喵喵币入场,储蓄罐还剩40枚。
- 正常运作(动态平衡):
- 9:01-9:30,每秒都有2枚新喵喵币加入储蓄罐。
- 新访客陆续到来,借走喵喵币;早来的访客离开,归还喵喵币。
- 储蓄罐的喵喵币数量在动态变化,但始终有喵喵币可用。
- 突发高峰(令牌快速消耗):
- 10:00,突然涌入50位访客!
- 储蓄罐里的喵喵币快速减少,刚好被全部借走。
- 服务员微笑着说:"欢迎光临,请享受您的时光~"
- 这体现出令牌桶算法的灵活特性,可以应对突发的流量高峰。
- 超出限制(令牌耗尽):
- 10:01,又来了20位访客,但储蓄罐已经空了。
- 服务员歉意地说:"非常抱歉,请稍等片刻,我们正在等待喵喵币归还或生成新的~"
- 这相当于触发了 429 Too Many Requests 错误。
- 逐渐恢复(令牌重新积累):
- 10:02开始,一些早先的访客离开,归还了喵喵币。
- 同时,每秒也在添加2枚新喵喵币。
- 等候的访客可以逐渐入场,但有些访客可能需要等待几秒。
- 服务员解释道:"感谢您的耐心,很快就能为您服务~"
- 平稳期(动态平衡恢复):
- 10:10后,进出访客数量趋于平衡。
- 归还的喵喵币和新生成的喵喵币能够满足新访客的需求。
- 储蓄罐中的喵喵币数量在一个相对稳定的范围内波动。
友好提示
每次访客"光临"时,我们都会告诉 Ta:
- 储蓄罐还剩多少喵喵币X-RateLimit-Remaining:剩余令牌数
- 储蓄罐最多能存多少喵喵币X-RateLimit-Limit:令牌桶容量
- 如果需要等待,我们会告诉 Ta 大约要等多久Retry-After:预计等待时间
这个"喵喵币"系统帮助我们在应对突发高峰时保持灵活,同时确保长期的稳定服务。它模拟了API请求的动态特性,既考虑了新请求的到来,也考虑了旧请求的完成。
这就是 喵福禄 REST API 速率限制的工作原理。我们希望通过这个生动的例子,能让您更容易理解这个概念。
加签与验签机制
加签与验签机制是保障您的应用与 喵福禄 之间的数据交互是安全的重要手段。它能够:
- 确保请求的真实性: 验证请求确实来自 喵福禄 ,而非恶意第三方。
- 保证数据完整性: 确保数据在传输过程中未被篡改。
- 防止重放攻击: 通过时间戳等机制,防止请求被重复使用。
应用场景
Webhooks通知
当您配置的 Webhooks 事件触发时, 喵福禄 会向您的配置的 Webhook 地址推送 HTTP POST 请求。在推送这些数据时, 喵福禄 会对数据进行签名处理。 您收到请求后,可通过验证签名来验证数据的合法性,以确保请求的真实性和数据完整性。详细内容请参阅:Webhooks通知。
签名算法
喵福禄 使用 HMAC-SHA256 算法对 HTTP 请求数据进行签名。这种验证机制广泛应用于 API 安全,有效的确保请求的完整性和真实性。
HMAC-SHA256是一种结合了哈希消息认证码(HMAC)和 SHA-256 哈希函数的安全算法,它是基于散列函数的消息认证码,由以下两个算法组成:
获取 App Secret
在 喵福禄 的管理应用,点击 创建应用 获取 App ID 与 App 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}
- 构造待签名字符串
- Method: HTTP 请求方法
- 空格: 分隔 Method 和 Domain
- Domain: HTTP 请求 Domain (Host + Port(非 80/443))
- Path: HTTP 请求路径
- 如果 Query 参数包含 meowflow_signature,则需要将其排除在签名计算之外
- Query 参数需要包含 meowflow_timestamp 参数,且该参数的值与 X-Meowflow-Timestamp 的值相同
- 按照字典序排序的 Query 参数的 Key
- Query 参数的 Key 和 Value 之间使用 = 连接,多个 Value 之间使用 , 连接
- 多个 Query 参数之间使用 & 连接
- 使用 ? + Query 参数字符串拼接为待签名字符串
- 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
上述流程图中 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 请求
在需要验签的应用场景下,当 喵福禄 向您的应用程序推送数据时,您需要验证签名以确保请求的真实性和数据完整性。
- 请您参阅 Query 签名流程 对请求参数进行处理并得到待签名字符串
- 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
- 将签名结果与原请求中的签名进行比对,如果一致,则请求合法,否则认为请求不合法
Body 请求
Body 请求是指使用 POST、PUT 或 PATCH 方法发送的请求
Body 签名流程
签名内容格式: {HTTPMethod} {Domain}{Path} {Body}{Timestamp}
- 构造待签名字符串
- Method: HTTP 请求方法
- 空格: 分隔 Method 和 Domain
- Domain: HTTP 请求 Domain (Host + Port(非 80/443))
- Path: HTTP 请求路径
- " " + 请求体的原始内容 + timestamp 拼接为待签名字符串
- 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
上述流程图中 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 请求
在需要验签的应用场景下,当 喵福禄 向您的应用程序推送数据时,您需要验证签名以确保请求的真实性和数据完整性。
- 请您参阅 Body 签名流程 对请求参数进行处理并得到待签名字符串
- 借助 App Secret 对待签名字符串使用 HMAC-SHA256 算法进行签名
- 将签名结果与原请求中的签名进行比对,如果一致,则请求合法,否则认为请求不合法
温馨提示:
- 喵福禄 的签名时间戳有效期为 5 分钟,即请求时间戳与当前时间戳的差值不超过 5 分钟。
- 您应该验证请求的时间戳,以防止重放攻击。您可以在签名验证时检查时间戳是否在有效期内。如果时间戳不在有效期内,则拒绝该请求。
- 当您向 喵福禄 发送请求时,您的时间戳应该与 喵福禄 的时间戳相差不超过 5 分钟。否则,请求将被拒绝。
签名与验签示例代码
为了能让您更好地理解数据签名与验证的过程,我们提供了不同语言和框架的示例代码。但是这些示例代码并不是生产环境的最佳实践,您需要根据自己的实际情况进行调整。
注意:
为了您的数据安全,请不要直接将以下代码用于生产环境,以免造成不必要的损失。
Java
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}
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<?php
2declare(strict_types=1);
3
4function sign(string $secret, string $contents): string {
5 return hash_hmac('sha256', $contents, $secret);
6}
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
1import hmac
2import hashlib
3
4def sign(secret: str, contents: str) -> str:
5 return hmac.new(secret.encode(), contents.encode(), hashlib.sha256).hexdigest()
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
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}
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
1const crypto = require('crypto');
2
3function sign(secret, contents) {
4 return crypto.createHmac('sha256', secret).update(contents).digest('hex');
5}
1import {createHmac} from 'crypto';
2
3export function sign(secret: string, contents: string): string {
4 return createHmac('sha256', secret).update(contents).digest('hex');
5}
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 中应该添加类似如下的代码:
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 事件类型。
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 请求字段说明
字段 | 类型 | 说明 |
---|---|---|
id | string | 推送记录ID |
webhookId | string | Webhooks 的 ID |
appId | string | 应用 ID |
event | string | 事件类型 |
objectId | string | 触发事件的对象的 ID |
payload | object | 事件详细信息,根据 event 不同,其数据结构也不相同 |
pushTime | string | 推送时间,格式为 yyyy-MM-dd'T'HH:mm:ss'Z' |
您需要做以下几件事情:
- 接收请求:您的服务器需要有一个公开的 URL,可以接收 喵福禄 发送的 HTTP POST 请求。
- 验证请求:您需要验证 喵福禄 发送的请求,以确保它来自 喵福禄。
- 处理请求:您需要处理 喵福禄 发送的请求,以执行您的业务逻辑。
- 响应请求:您需要向 喵福禄 发送一个 HTTP 响应。响应的状态码应该是 2xx,以确认您已成功接收并处理了请求。
验证 Webhooks 请求
为了验证 喵福禄 发送的 Webhooks 请求,您需要使用签名验证机制。详细内容请参阅:数据加签与验签。
处理 Webhooks 请求
当您接收到 喵福禄 发送的 Webhooks 请求时,您需要解析请求的 JSON 数据,以获取事件的详细信息。您可以根据事件的类型和数据执行相应的业务逻辑。
响应 Webhooks 请求
当您成功接收并处理了 喵福禄 发送的 Webhooks 请求后,您需要向 喵福禄 发送一个 HTTP 响应。响应的状态码应该是 2xx,以确认您已成功接收并处理了请求。如果您返回的状态码不是 2xx, 喵福禄 将认为请求失败,并会在一段时间后重试。 推送重试的时间间隔会逐渐增加,直到达到最大重试次数(默认为 10 次)。
Webhooks 事件类型
支持以下 Webhooks 事件类型:
- 工序流任务
工序流任务
事件名称 | 是否可用 | 描述 | 相关关联方法 |
---|---|---|---|
workflowTask.completed | Y | 任务处理完成(成功处理) | 获取工序流任务 |
workflowTask.completed
当工序流任务处理完成时,会触发 workflowTask.completed 事件。
1{
2 "id":"0",
3 "webhookId":"0",
4 "appId":"0",
5 "event":"workflowTask.completed",
6 "objectId":"0",
7 "payload":{
8 "jobs":[
9 {
10 "id":"0",
11 "sku":"string",
12 "appId":"0",
13 "reason":"处理成功",
14 "customId":"string",
15 "duration":0,
16 "createdAt":"2024-11-20T06:15:10.751Z",
17 "updatedAt":"2024-11-20T08:09:47.037Z",
18 "totalPrice":{
19 "amount":"0.1",
20 "format":"¥ 0.10",
21 "currency":"CNY",
22 "currencyInfo":{
23 "symbol":"CN¥",
24 "currencyCode":"CNY",
25 "narrowSymbol":"¥",
26 "defaultFractionDigits":2
27 }
28 },
29 "viewStatus":2,
30 "workflowId":"0",
31 "completedAt":"2024-11-20T08:09:47.03Z",
32 "processOutputs":{
33 "PHOTO_AI":"string",
34 "CUSTO_TEXT":"string"
35 },
36 "startExecutionAt":"2024-11-20T08:09:46.401Z"
37 }
38 ]
39 },
40 "pushTime":"2024-11-20T08:10:17Z"
41 }
Manage App
API 服务服务状态
获取 API 访问令牌
请求数据
授权类型必须是 client_credentials
Example:client_credentials
客户端 ID
Example:41518078235770880
客户端密钥
Example:wqwr2wdq...
响应
响应状态码
0
响应描述
OK
业务数据
accessToken
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
令牌类型
bearer
过期时间(秒)
3600
权限范围
read write
应用ID
41518078235770880
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}
创建工序流任务
请求
请将 Access Token 放入 Authorization 头部发送
示例:Authorization: Bearer ${Access Token}
请求数据
任务项, 数组长度最大限制 20
Example:[{ "workflowId": "0", "customId": "string", "sku": "string", "data": { } }]
SKU,该值与 工序流ID 二选一
Example:SKU
工序流ID,该值与 SKU 二选一
Example:5813548736546847
一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符
Example:53424155105734656
任务数据
Example:{}
响应
响应状态码
0
响应描述
OK
业务数据
任务批次ID
41518078235770880
错误任务列表
工序流 ID
41518078235770880
SKU
SKU
一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符
41518078235770880
失败原因
reason
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 "sku": "string",
10 "data": {}
11 }
12 ]
13 }'
1{
2 "code": 0,
3 "message": "操作成功",
4 "data": {
5 "groupId": "0",
6 "errorItems": [
7 {
8 workflowId: "0",
9 customId: "string",
10 sku: "string",
11 reason: "string",
12 },
13 ],
14 },
15}
获取工序流任务
请求
请将 Access Token 放入 Authorization 头部发送
示例:Authorization: Bearer ${Access Token}
请求参数
一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符
Example:53424155105734656
任务 ID
Example:41518078235770880
绑定的SKU,多个使用英文逗号分隔,最多 10 个
Example:sku1,sku2
上面三个参数至少需要一个
响应
响应状态码
0
响应描述
OK
业务数据
总数
0
任务列表
任务 ID
41518078235770880
创建时间
2023-09-01T12:00:00.999+08:00
更新时间
2023-09-01T12:00:00.999+08:00
一个由客户端自定义的唯一标识符 , 用于协调客户端与 喵福禄 业务交互。长度限制为 40 个字符
53424155105734656
工序流 ID
41518078235770880
可视状态:0=无,1=系统异常,2=处理成功,3=处理失败,4=处理中
1
失败原因
reason
SKU
sku
开始执行时间 (格式: RFC3339Milli)
2023-09-01T12:00:00.999+08:00
完成时间(成功或失败) (格式: RFC3339Milli)
2023-09-01T12:00:00.999+08:00
执行总耗时(单位: ms)
1000
总价格
金额
100.00
货币代码(ISO 4217)
CNY
格式化金额
¥ 100.00
货币信息
货币代码(ISO 4217)
CNY
货币数字代码
156
货币符号
CN¥
货币符号缩写
¥
货币名称
人民币
货币小数位数
2
工序处理结果
{}
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 "count": 0,
6 "list": [
7 {
8 "id": "0",
9 "createdAt": "2023-09-01T12:00:00.999+08:00",
10 "updatedAt": "2023-09-01T12:00:00.999+08:00",
11 "appId": "0",
12 "customId": "string",
13 "workflowId": "0",
14 "viewStatus": 1,
15 "reason": "string",
16 "sku": "string",
17 "startExecutionAt": "2023-09-01T12:00:00.999+08:00",
18 "completedAt": "2023-09-01T12:00:00.999+08:00",
19 "duration": 0,
20 "totalPrice": {
21 "amount": "string",
22 "currency": "string",
23 "format": "string",
24 "currencyInfo": {
25 "currencyCode": "string",
26 "numericCode": "string",
27 "symbol": "string",
28 "narrowSymbol": "string",
29 "displayName": "string",
30 "defaultFractionDigits": 0
31 }
32 },
33 "processOutputs": {}
34 }
35 ]
36 }
37}