# Raise Open API

版本 `1.2.0`。[下载 OpenAPI 文件（YAML）](/openapi/open-v1.yaml)

## 概述

给应用后端用的开放接口（主版本 v1）。调用根地址是 `https://api.{域名}/open/v1`（`https://app.{域名}/open/v1` 同样可用）。
本文件里的路径已经去掉了这个前缀：`GET /whoami` 就是 `GET https://api.{域名}/open/v1/whoami`。

### 第一次调用

1. 在开发者平台创建应用，拿到 `client_id` 和密钥（测试环境以 `kwo_test_` 开头，正式以 `kwo_live_` 开头；旧密钥 `kwo_` 也是正式）。
2. `POST /oauth/token`，`grant_type=client_credentials`。商店应用（应用级凭证）必须带 `tenant`；租户自建应用（绑定在一个组织上）不要带，或只能带它自己的组织。
3. 用返回的 `access_token` 调 `GET /whoami` 或 `GET /ping`。令牌 300 秒过期，SDK 会在到期前 60 秒自动换。

代表一位终端客户（小程序用户）调用 `/me…` 时，用 `grant_type=urn:raise:grant-type:customer`，带上后台已经验证过的 `phone`。

### 约定

* **认证** `Authorization: Bearer <access_token>`。换令牌本身用 HTTP Basic（`client_id:client_secret`）或把这两个字段放在正文里。
* **错误** 状态 ≥ 400 的正文都是 `{"error":{"code","message","requestId","docUrl","details"}}`。`code` 稳定，用它分支；`message` 给人看。`details` 没内容时是 `{}`。`requestId` 与响应头 `X-Request-Id` 相同，报问题时带上。
* **列表** `?limit=`（默认 20，最大 100）`&cursor=` → `{"data":[…],"nextCursor":"…" 或 null}`。没有下一页时 `nextCursor` 为 `null`。会员 `/me` 下的短列表（自己的会员、课包等）不翻页，字段名见各操作。
* **幂等** 除 `/oauth/token` 外每个 `POST` 收 `Idempotency-Key`（1–255 字符）。范围是应用 + 组织（客户令牌再加上客户）。保存 24 小时。同键同方法同路径同正文 → 重放第一次的回答，并带 `Idempotent-Replayed: true`。同键不同正文 → 409 `idempotency_conflict`。第一次还在处理 → 409 `idempotency_in_progress`。状态 ≥ 500 的回答不保存。SDK 会在 POST 上自动生成 uuid v4。
* **限流** 认证过的调用都带 `X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset`。429 另带 `Retry-After`（秒）。`Raise-Environment: test|live` 说明这把令牌属于哪边。
* **时间** RFC 3339 UTC。**金额** 整数最小货币单位 + `currency`。JSON 字段 camelCase；令牌接口的成功正文用 RFC 6749 的名字（`access_token`、`expires_in`…）。

用 Go 模块 `github.com/21stware/raise-go` 或 npm 包 `@21stware/raise-sdk`，不要自己拼签名和重试。

## 地址

- `https://api.{domain}/open/v1`
  - `domain`：平台域名（默认 `example.com`）

## 认证方式

| 名称 | 类型 | 说明 |
|---|---|---|
| `bearer` | http bearer | POST /oauth/token 换到的短令牌。Authorization: Bearer &lt;access_token> |
| `basic` | http basic | 换令牌时的应用密钥。用户名是 client_id，密码是 client_secret。也可以把这两个字段放进正文。 |

## oauth

用应用密钥换短令牌。SDK 内部调用，应用代码不要自己打这条

### `POST /oauth/token`

用应用密钥换一把短令牌（租户令牌或客户令牌）

这条接口不要用 Bearer，也不要带 Idempotency-Key。SDK 的传输层会调它。

认证：HTTP Basic（`client_id:client_secret`）或把 `client_id`、`client_secret` 放进正文。未知的 id 和错误的密钥都回答 401 `invalid_client`，并带 `WWW-Authenticate: Basic`。

正文 `application/x-www-form-urlencoded` 或 `application/json` 都可以。

`grant_type=client_credentials`：换租户令牌，300 秒。`tenant` 对应用级凭证（商店应用、凭证不绑在一个组织上）是必填；对租户绑定凭证必须省略或等于它自己的组织，否则 400 `tenant_required`。`scope` 可选，空格分隔，只能是已经授予的子集。

`grant_type=urn:raise:grant-type:customer`：换客户令牌，900 秒。`phone` 必填（后台自己验证过的号码，平台不再验）。`customer_id` 可选，在 409 `ambiguous_customer` 之后用来指定是哪一位。需要范围 `customer:assert`。没有对应会员时 200 `{"matched":false}`，不发令牌、不建客户。

`grant_type=authorization_code`：员工授权（代某位员工，在工作台之外：手机、桌面、命令行、外部 agent）。先把人带到身份服务的授权地址（client_id = 应用的 OIDC 客户端，code_challenge + `S256`，redirect_uri 必须在开发者平台登记过）；人登录、在同意页看过范围并同意后，回调带回 `code`。再到这里换：`code`、code_verifier、redirect_uri、`client_id` 必填；机密客户端另用应用密钥认证（HTTP Basic 或 `client_secret`），公开客户端不带密钥。得到员工令牌（600 秒，`sub` 是这个人）与 refresh_token。人没同意过是 403 `consent_required`。

`grant_type=refresh_token`：用 refresh_token 续员工令牌。每用一次换一把新的（30 天滚动）；用过的再用一次，整条链作废（400 `invalid_grant`）。人或管理员吊销授权、应用停用、凭证吊销后都是 400 `invalid_grant`，已发出的员工令牌 30 秒内失效（401 `consent_revoked`）。员工令牌能做的 = 人同意的范围 ∩ 组织此刻授予应用的范围 ∩ 这个人此刻的角色与数据范围。

测试密钥只能打测试企业；正式密钥不能打测试企业。否则 403 `wrong_environment`。应用没装、关掉、组织停用是 403 `not_installed`。一把范围都没授是 403 `no_scope`。密钥吊销或过期是 401 `credential_ended`。来源 IP 不在允许名单是 403 `address_not_allowed`。

同一地址失败太多次是 429 `too_many_attempts`（`Retry-After: 60`）。换令牌本身也有次数限制（429 `rate_limited`）。

- 认证：`basic` 或 不带凭证
- operationId：`createToken`

**请求正文（必填）**（`application/x-www-form-urlencoded`、`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `grant_type` | string | 是 | client_credentials 换租户令牌；urn:raise:grant-type:customer 换客户令牌；authorization_code / refresh_token 换员工令牌<br>可选值：`client_credentials`、`urn:raise:grant-type:customer`、`authorization_code`、`refresh_token` |
| `client_id` | string |  | 未用 HTTP Basic 时必填 |
| `client_secret` | string |  | 未用 HTTP Basic 时必填 |
| `tenant` | string |  | 应用级凭证必填。租户绑定凭证必须省略或等于它自己的组织 |
| `scope` | string |  | 空格分隔的范围子集。省略则给已授予的全部 |
| `phone` | string |  | 客户授权必填。数字，可带前导 +。空格和短横忽略。大陆手机也可写成 86… / +86… |
| `customer_id` | string |  | 客户授权可选。在 ambiguous_customer 之后指定是哪一位 |
| `code` | string |  | authorization_code 必填。身份服务回调带回的授权码 |
| `code_verifier` | string |  | authorization_code 必填。PKCE 原文（S256） |
| `redirect_uri` | string |  | authorization_code 必填。与授权请求里的一致，且在开发者平台登记过 |
| `refresh_token` | string |  | refresh_token 必填。kwr_ 开头，只能用一次 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 换到令牌：`access_token`、`token_type=Bearer`、`expires_in`、`scope`、`tenant`、`environment`。客户授权另带 `customer`。 客户授权找不到人：`{"matched":false}`，没有令牌。 |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | `invalid_client`：client_id 不存在或 client_secret 不对。见 [InvalidClient](#resp-invalidclient) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 409 | 见 [Conflict](#resp-conflict) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 501 | `not_implemented`。见 [NotImplemented](#resp-notimplemented) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

以下其一：

**Token**：RFC 6749 成功正文。customer 只在客户授权时出现

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `access_token` | string | 是 |  |
| `token_type` | string | 是 | 固定为 `"Bearer"` |
| `expires_in` | integer | 是 | 秒。租户令牌 300，客户令牌 900，员工令牌 600 |
| `scope` | string | 是 | 空格分隔。客户令牌不含 customer:assert |
| `tenant` | string | 是 |  |
| `environment` | string | 是 | 可选值：`test`、`live` |
| `customer` | string |  | 客户中心 id。仅客户授权 |
| `refresh_token` | string |  | 仅员工授权。下一次续期用它，30 天不用就过期 |
| `user` | string |  | 仅员工授权。这位员工的用户 id |

**CustomerMiss**：客户授权找不到这个号码下的会员。不发令牌、不建客户

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `matched` | boolean | 是 | 固定为 `false` |

## platform

令牌身份与连通性

### `GET /whoami`

这把令牌现在是谁：组织、应用、凭证和仍然有效的范围

不需要特定范围。任何还活着的集成令牌都可以调。返回的 `scopes` 是令牌里的范围与此刻授予的交集：管理员收回的范围最多 30 秒内从这里消失。
用它确认换到的令牌打的是哪个组织和哪把凭证。

- 认证：`bearer`
- operationId：`getWhoami`

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {tenant, app, credential, scopes} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenant` | string | 是 |  |
| `app` | string | 是 |  |
| `credential` | string | 是 | 换出这把令牌的 client_id |
| `scopes` | string[] | 是 | 令牌范围与此刻授予的交集 |
| `user` | string |  | 员工令牌的员工；租户令牌为空 |
| `roles` | string[] |  | 员工令牌：这个人此刻在组织里的角色（每次调用现读）；租户令牌为空 |
| `environment` | string |  | 可选值：`test`、`live` |

### `GET /ping`

连通性探测，需要范围 platform:ping，正文只有 {ok:true}

需要范围 `platform:ping`。不返回组织里的任何数据，只说明这把令牌还能用。没有该范围是 403 `scope_required`（这次调用已经计入限流）。

- 认证：`bearer`
- operationId：`getPing`

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {ok: true} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `ok` | boolean | 是 | 固定为 `true` |

## tables

多维表只读（必须经视图；个人字段永远不返回）

### `GET /tables/{table}/views/{view}/rows`

经视图列出一张多维表的行（不含个人字段）

需要这张表的范围：租户自建表是 `tables.base:read`；系统表 `sys_<id>` 是 `tables.<id>:read`。表还必须「开放给项目」。缺范围是 403 `scope_required`（消息里会写出缺的那条，方便你去要）。

后台模式令牌（租户令牌）按视图自己的筛选读行，员工专属视图也可以。客户模式令牌按视图的行规则来（own-read / own-write 只看见断言客户的行）。个人（PII）列永远不返回。

表、视图不存在，或表未开放，或这条令牌进不了这个视图：一律 404 `not_found`，避免探测。

查询与造物的 `data.list` 相同：`limit`（默认 20，1–100）、`cursor`、`sort`（字段 id，前面加 `-` 表示降序，逗号分隔）、`filter[字段][运算符]=值`。运算符：`eq`、`ne`、`in`、`lt`、`lte`、`gt`、`gte`、`contains`、`between`。只能筛、只能排视图里出现的字段，否则 400 `invalid_request`。

列表形状是 `data` + `nextCursor`。每一行是 `{id, version, fields, createdAt}`，`fields` 只有视图里的列。

- 认证：`bearer`
- operationId：`listTableRows`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `table` | path | string | 是 | 表名。系统表以 sys_ 开头；否则是租户自建表 |
| `view` | path | string | 是 | 视图 id |
| `limit` | query | integer |  | 每页条数，默认 20，最大 100<br>默认 `20`<br>最小 1，最大 100 |
| `cursor` | query | string |  | 上一页返回的 nextCursor。第一页省略 |
| `sort` | query | string |  | 字段 id，逗号分隔；前面加 - 表示降序。只能是视图里的字段。 |
| `filter` | query | map[string, map[string, string]] |  | 只按视图里的字段筛选。写成 filter[字段][运算符]=值。 例：filter[status][eq]=active。in 的值用逗号分隔；between 的值是 lo,hi。 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 一页行 |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `data` | Row[] | 是 |  |
| `data[].id` | string | 是 |  |
| `data[].version` | integer | 是 | 乐观并发版本 |
| `data[].fields` | object | 是 |  |
| `data[].createdAt` | string(date-time) |  |  |
| `nextCursor` | string，可为 null | 是 |  |

### `GET /tables/{table}/views/{view}/rows/{id}`

经视图读取多维表的一行

范围和可见性与列表相同。行不在视图里、不是这位客户的、或 id 不存在：404 `not_found`。个人字段不返回。

- 认证：`bearer`
- operationId：`getTableRow`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `table` | path | string | 是 | 表名。系统表以 sys_ 开头；否则是租户自建表 |
| `view` | path | string | 是 | 视图 id |
| `id` | path | string | 是 | 行 id |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 这一行 |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `version` | integer | 是 | 乐观并发版本 |
| `fields` | object | 是 |  |
| `createdAt` | string(date-time) |  |  |

## customers

客户档案。电话按员工界面打码；customer:write 可按手机号创建或找到，并改姓名与标签

### `GET /customers`

列出组织里的客户（电话已打码）

需要范围 `customer:read`。租户令牌。列表形状是 `data` + `nextCursor`。默认 20 条，最大 100。

`phone` 按客户中心的查找：整段号码（大陆 11 位或带国家码）走身份查找；也可以是屏幕上打码后仍能看见的数字。`updatedSince` 是 RFC 3339，只留下 `lastTouchAt`（没有则 `createdAt`）不早于该时刻的客户。

`phone` 是员工列表上的打码形式（与 `customer:write` 无关；揭开号码仍是员工在客户应用里的 reveal）。`tags` 是标签 id。不返回明文号码、邮箱、备注（客户中心没有备注字段）。

- 认证：`bearer`
- operationId：`listCustomers`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `limit` | query | integer |  | 每页条数，默认 20，最大 100<br>默认 `20`<br>最小 1，最大 100 |
| `cursor` | query | string |  | 上一页返回的 nextCursor。第一页省略 |
| `phone` | query | string |  | 按号码查找（整段或打码后仍可见的数字） |
| `updatedSince` | query | string(date-time) |  | RFC 3339 UTC。只列出该时刻及之后有过接触（或新建）的客户 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 一页客户。没有下一页时 nextCursor 为 null |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `data` | Customer[] | 是 |  |
| `data[].id` | string | 是 |  |
| `data[].name` | string | 是 |  |
| `data[].phone` | string |  | 员工列表上的打码形式。没有电话则省略 |
| `data[].stage` | string | 是 | lead、customer 或 dormant（按最近接触算） |
| `data[].tags` | string[] | 是 | 标签 id |
| `data[].createdAt` | string(date-time) | 是 |  |
| `data[].lastTouchAt` | string(date-time) | 是 |  |
| `data[].identities` | CustomerIdentity[] |  | 详情才有。masked 已打码，没有明文 value |
| `data[].identities[].kind` | string | 是 | phone、email、openid 等 |
| `data[].identities[].masked` | string | 是 |  |
| `data[].identities[].verified` | boolean | 是 |  |
| `nextCursor` | string，可为 null | 是 |  |

### `POST /customers`

按手机号创建客户，或返回已经存在的那位

需要范围 `customer:write`。租户令牌。`phone` 必填，否则 422。走客户中心的 Resolve：号码已有客户则 200 并返回那位（不改姓名、不加标签、不发事件）；没有则 201 并写入 `customer.created`。

新建时可选 `name`、`tags`（标签 id）。请带 Idempotency-Key。返回的 `phone` 已打码。

- 认证：`bearer`
- operationId：`createCustomer`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `Idempotency-Key` | header | string |  | 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答<br>最短 1，最长 255 |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `phone` | string | 是 | 必填。大陆 11 位或带国家码 |
| `name` | string |  | 新建时使用。号码已有客户时忽略 |
| `tags` | string[] |  | 标签 id。只在新建时加上 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 这个号码已经有客户，返回那位（未改） |
| 201 | 新建的客户 |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 409 | 见 [Conflict](#resp-conflict) |
| 413 | `payload_too_large`：正文太大。见 [PayloadTooLarge](#resp-payloadtoolarge) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `name` | string | 是 |  |
| `phone` | string |  | 员工列表上的打码形式。没有电话则省略 |
| `stage` | string | 是 | lead、customer 或 dormant（按最近接触算） |
| `tags` | string[] | 是 | 标签 id |
| `createdAt` | string(date-time) | 是 |  |
| `lastTouchAt` | string(date-time) | 是 |  |
| `identities` | CustomerIdentity[] |  | 详情才有。masked 已打码，没有明文 value |
| `identities[].kind` | string | 是 | phone、email、openid 等 |
| `identities[].masked` | string | 是 |  |
| `identities[].verified` | boolean | 是 |  |

**201 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `name` | string | 是 |  |
| `phone` | string |  | 员工列表上的打码形式。没有电话则省略 |
| `stage` | string | 是 | lead、customer 或 dormant（按最近接触算） |
| `tags` | string[] | 是 | 标签 id |
| `createdAt` | string(date-time) | 是 |  |
| `lastTouchAt` | string(date-time) | 是 |  |
| `identities` | CustomerIdentity[] |  | 详情才有。masked 已打码，没有明文 value |
| `identities[].kind` | string | 是 | phone、email、openid 等 |
| `identities[].masked` | string | 是 |  |
| `identities[].verified` | boolean | 是 |  |

### `GET /customers/{id}`

读取一位客户（电话已打码）

需要范围 `customer:read`。不存在是 404 `not_found`。`phone` 与 `identities[].masked` 是打码形式；`identities` 不含明文 `value`。揭开号码不是这个接口的事。

- 认证：`bearer`
- operationId：`getCustomer`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | path | string | 是 | 客户中心 id |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 这位客户 |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `name` | string | 是 |  |
| `phone` | string |  | 员工列表上的打码形式。没有电话则省略 |
| `stage` | string | 是 | lead、customer 或 dormant（按最近接触算） |
| `tags` | string[] | 是 | 标签 id |
| `createdAt` | string(date-time) | 是 |  |
| `lastTouchAt` | string(date-time) | 是 |  |
| `identities` | CustomerIdentity[] |  | 详情才有。masked 已打码，没有明文 value |
| `identities[].kind` | string | 是 | phone、email、openid 等 |
| `identities[].masked` | string | 是 |  |
| `identities[].verified` | boolean | 是 |  |

### `PATCH /customers/{id}`

改客户的姓名或标签

需要范围 `customer:write`。可改 `name`、`tags`（标签 id 列表；空数组表示去掉全部可改的标签）。带上 `phone` 是 422（号码不能改）。带上 `notes` 是 422（客户中心没有备注字段）。其它未知字段忽略。

改了字段时写入 `customer.updated`。企业微信等外来标签不能在这里摘掉，会 422。

- 认证：`bearer`
- operationId：`updateCustomer`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | path | string | 是 | 客户中心 id |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `name` | string |  |  |
| `tags` | string[] |  | 标签 id。空数组表示去掉全部可改的标签 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 改完后的客户 |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `name` | string | 是 |  |
| `phone` | string |  | 员工列表上的打码形式。没有电话则省略 |
| `stage` | string | 是 | lead、customer 或 dormant（按最近接触算） |
| `tags` | string[] | 是 | 标签 id |
| `createdAt` | string(date-time) | 是 |  |
| `lastTouchAt` | string(date-time) | 是 |  |
| `identities` | CustomerIdentity[] |  | 详情才有。masked 已打码，没有明文 value |
| `identities[].kind` | string | 是 | phone、email、openid 等 |
| `identities[].masked` | string | 是 |  |
| `identities[].verified` | boolean | 是 |  |

## org

组织通讯录（成员与组织名）。电话与邮箱只在同时持有 org.contacts:read 时返回

### `GET /members`

列出组织通讯录里的成员

需要范围 `org.members:read`。租户令牌。返回 `{data:[{id, name, title, departments, status}], nextCursor}`。默认 20 条，最大 100。按账号 id。

`email` 与 `phone` 只在同时持有 `org.contacts:read` 时出现。生产环境的通讯录是在职的人（不含机器账号）；状态以目录为准。

- 认证：`bearer`
- operationId：`listMembers`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `limit` | query | integer |  | 每页条数，默认 20，最大 100<br>默认 `20`<br>最小 1，最大 100 |
| `cursor` | query | string |  | 上一页返回的 nextCursor。第一页省略 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 一页成员。没有下一页时 nextCursor 为 null |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `data` | Member[] | 是 |  |
| `data[].id` | string | 是 | 账号 id |
| `data[].name` | string | 是 |  |
| `data[].title` | string | 是 | 职务。没有则是空字符串 |
| `data[].departments` | string[] | 是 | 部门名称，主部门在前 |
| `data[].status` | string | 是 | 目录里的状态，如 active、invited |
| `data[].email` | string |  |  |
| `data[].phone` | string |  |  |
| `nextCursor` | string，可为 null | 是 |  |

### `GET /org`

当前令牌所在组织的 id 与名称

需要范围 `org.members:read`。返回 `{tenant, name}`。名称来自组织档案；没有名称时是空字符串。

- 认证：`bearer`
- operationId：`getOrg`

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {tenant, name} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tenant` | string | 是 |  |
| `name` | string | 是 | 组织显示名。没有则是空字符串 |

## me

会员客户模式：这位客户自己的会员、课包、预约和待确认核销。新地址是会员应用自己的域名 `https://membership.apps.{domain}/open/v1/me…`（同一组路由、同一把客户令牌）；`api.{domain}` 上的旧地址照常应答到 2027-05-01，应答带 `Deprecation`、`Sunset` 与指向新地址的 `Link: rel=successor-version`

### `GET /me`

这把客户令牌对应的会员（id、编号、姓名、状态）

需要范围 `membership.me:read`。只要客户令牌（租户令牌是 403 `customer_token_required`）。读操作不检查断言有多老。

`members` 是这个组织里 `customer_id` 为令牌客户的全部会员，按创建时间，不含已合并、已关闭。一个号码在多家店可以是多条会员，只要客户 id 相同。没有会员时是 `[]`。

不返回：电话、备注、标签、教练、核销码、金额、病历。

- 认证：`bearer`
- operationId：`listMeMembers`

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {members: [MeMember]} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `members` | MeMember[] | 是 |  |
| `members[].id` | string | 是 |  |
| `members[].no` | string | 是 | 会员编号 |
| `members[].name` | string | 是 |  |
| `members[].status` | string | 是 | merged 和 closed 不会出现<br>可选值：`active`、`frozen`、`expired` |

### `GET /me/packages`

这位客户名下会员的有效或冻结课包

需要范围 `membership.me:read`。认证与 GET /me 相同。

只列出存储状态为 `active` 或 `frozen` 的课包（已退、作废、已转让不出现）。`status` 是读的时候算出来的，存储仍是 `active` 时也可能是 `not_started`、`expired`、`used_up`。`unlimited` 仅在 kind 为 `period`（期卡）时为 true。`courses` 为空表示适用全部课程。

不返回：备注、金额、销售、流水、券、每日/每周上限。

- 认证：`bearer`
- operationId：`listMePackages`

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {packages: [MePackage]} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `packages` | MePackage[] | 是 |  |
| `packages[].id` | string | 是 |  |
| `packages[].memberId` | string | 是 |  |
| `packages[].name` | string | 是 |  |
| `packages[].kind` | string | 是 | 可选值：`count`、`period`、`count_period` |
| `packages[].unlimited` | boolean | 是 | 仅 kind 为 period 时为 true |
| `packages[].totalTimes` | integer | 是 |  |
| `packages[].remaining` | integer | 是 |  |
| `packages[].courses` | string[] | 是 | 课程 id。空表示全部课程 |
| `packages[].startsOn` | string |  | YYYY-MM-DD。未设则省略 |
| `packages[].endsOn` | string |  | YYYY-MM-DD。未设则省略 |
| `packages[].state` | string | 是 | 存储状态。只列出这两种<br>可选值：`active`、`frozen` |
| `packages[].status` | string | 是 | 读的时候算出来<br>可选值：`active`、`not_started`、`frozen`、`expired`、`used_up` |

### `GET /me/bookings`

这位客户在一个日期窗口里的预约，并给出此刻取消要扣几次

需要范围 `membership.me:read`。`from`、`to` 是门店时区的日期 `YYYY-MM-DD`，两端都包含。省略 `from` 为昨天；省略 `to` 为 `from` 加 62 天。`to` 早于 `from`、不是日期、或跨度超过 124 天：422 `validation_failed`。

`status` 有值时按精确状态过滤（未知值得到空列表，不是 422）。最多 200 条，按开始时间再按 id。

`cancelCharge` 只在 `status` 为 `booked` 时计算（免费取消窗口内是 0，否则是门店的晚取消扣次，0 或 1）。`coachName` 是员工显示名，没有则省略。

不返回：备注、会员姓名或编号、教练 id、金额、颜色、课包 id、核销 id。

- 认证：`bearer`
- operationId：`listMeBookings`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `from` | query | string |  | YYYY-MM-DD，门店时区。默认昨天 |
| `to` | query | string |  | YYYY-MM-DD，包含。默认 from 加 62 天。跨度最多 124 天 |
| `status` | query | string |  | 精确预约状态。未知值得到空列表 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {bookings: [MeBooking]} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `bookings` | MeBooking[] | 是 |  |
| `bookings[].id` | string | 是 |  |
| `bookings[].memberId` | string | 是 |  |
| `bookings[].sessionId` | string | 是 |  |
| `bookings[].courseName` | string | 是 |  |
| `bookings[].coachName` | string |  | 员工显示名。没有则省略 |
| `bookings[].startsAt` | string(date-time) | 是 |  |
| `bookings[].endsAt` | string(date-time) | 是 |  |
| `bookings[].room` | string | 是 | 总是出现，可能是空字符串 |
| `bookings[].status` | string | 是 | 可选值：`booked`、`waitlist`、`attended`、`completed`、`cancelled`、`no_show` |
| `bookings[].cancelCharge` | integer | 是 | 现在取消要扣几次。非 booked 为 0。门店设置为 0 或 1 |

### `POST /me/bookings`

以这位客户的一名会员预约一节未开始的开放课（满员进候补）

需要范围 `membership.booking:write`。这是写操作：客户断言超过 10 分钟是 401 `assertion_stale`，请重新断言。

正文 `{memberId, sessionId}`，都必填，否则 422 `validation_failed`。`memberId` 必须是 GET /me 里的会员，否则 404 `not_found`。课次不存在是 422，不是 404。走门店自己的预约规则（会员状态、课次开放且未开始、预约窗口、时间重叠、可用课包、容量）。满员时 201 且 `status` 为 `waitlist`。

已经预约过是 422；并发下唯一约束冲突是 409 `conflict`。请带 `Idempotency-Key`。

- 认证：`bearer`
- operationId：`createMeBooking`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `Idempotency-Key` | header | string |  | 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答<br>最短 1，最长 255 |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `memberId` | string | 是 | GET /me 返回的会员 id |
| `sessionId` | string | 是 | GET /me/sessions 返回的课次 id |

**响应**

| 状态 | 说明 |
|---|---|
| 201 | 预约本身（MeBooking），不包裹。status 为 booked 或 waitlist |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 409 | 见 [Conflict](#resp-conflict) |
| 413 | `payload_too_large`：正文太大。见 [PayloadTooLarge](#resp-payloadtoolarge) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**201 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 |  |
| `memberId` | string | 是 |  |
| `sessionId` | string | 是 |  |
| `courseName` | string | 是 |  |
| `coachName` | string |  | 员工显示名。没有则省略 |
| `startsAt` | string(date-time) | 是 |  |
| `endsAt` | string(date-time) | 是 |  |
| `room` | string | 是 | 总是出现，可能是空字符串 |
| `status` | string | 是 | 可选值：`booked`、`waitlist`、`attended`、`completed`、`cancelled`、`no_show` |
| `cancelCharge` | integer | 是 | 现在取消要扣几次。非 booked 为 0。门店设置为 0 或 1 |

### `POST /me/bookings/{id}/cancel`

取消这位客户的预约；晚取消且要扣次时必须 acceptCharge

需要范围 `membership.booking:write`。写操作（10 分钟后 `assertion_stale`）。`{id}` 必须是这位客户的预约，否则 404 `not_found`。

正文可选 `{acceptCharge, reason}`。可以不带正文。`reason` 最多 200 字，否则 422 `validation_failed`。

预约是 `booked` 且现在取消要扣次（与列表里的 `cancelCharge` 相同）且 `acceptCharge` 不是 true：409 `conflict`，什么都不取消。候补的 `cancelCharge` 为 0，不需要 `acceptCharge`。已到场或已完成不能取消（422）。

200 里的 `charged` 是实际扣掉的次数，即使带了 `acceptCharge: true` 也可能是 0（没有可扣的课包，或期卡）。

- 认证：`bearer`
- operationId：`cancelMeBooking`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | path | string | 是 | 预约 id |
| `Idempotency-Key` | header | string |  | 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答<br>最短 1，最长 255 |

**请求正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `acceptCharge` | boolean |  | 列表里 cancelCharge 大于 0 时必须为 true |
| `reason` | string |  | 最多 200 字 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {cancelled: true, charged}。charged 是实际扣次，可以为 0 |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 409 | 见 [Conflict](#resp-conflict) |
| 413 | `payload_too_large`：正文太大。见 [PayloadTooLarge](#resp-payloadtoolarge) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `cancelled` | boolean | 是 | 固定为 `true` |
| `charged` | integer | 是 | 实际扣次，可以为 0 |

### `GET /me/sessions`

这位客户还能预约的开放课次，尚未开始，最多 31 天

需要范围 `membership.me:read`。`from`、`to` 是门店时区日期，两端包含。省略 `from` 为今天；省略 `to` 为 `from` 加 6 天。日期不合法、`to` 早于 `from`、或超过 31 天：422 `validation_failed`。

只返回 `status=open` 且开始时间仍在未来的课次（今天已经开始的不算）。可选 `course` 为课程 id（未知 id 得到空列表）。最多 500 条，按开始时间再按课程名。

`spotsLeft` 为 max(0, 容量 − booked/attended/completed)。0 表示再预约会进候补。候补不占 `spotsLeft`。

- 认证：`bearer`
- operationId：`listMeSessions`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `from` | query | string |  | YYYY-MM-DD。默认今天（门店时区） |
| `to` | query | string |  | YYYY-MM-DD，包含。默认 from 加 6 天。跨度最多 31 天 |
| `course` | query | string |  | 课程 id。精确过滤 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {sessions: [MeSession]} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sessions` | MeSession[] | 是 |  |
| `sessions[].id` | string | 是 |  |
| `sessions[].courseId` | string | 是 |  |
| `sessions[].courseName` | string | 是 |  |
| `sessions[].coachName` | string |  | 员工显示名。没有则省略 |
| `sessions[].startsAt` | string(date-time) | 是 |  |
| `sessions[].endsAt` | string(date-time) | 是 |  |
| `sessions[].room` | string | 是 | 总是出现，可能是空字符串 |
| `sessions[].spotsLeft` | integer | 是 | max(0, 容量 − booked/attended/completed)。0 表示再预约会进候补 |

### `GET /me/redemption-requests`

前台请这位客户确认的核销（轮询；没有推送）

需要范围 `membership.me:read`。没有推送，小程序轮询这一条。

`state` 省略或 `pending`：仍待确认且未过期。`all`：最近 30 天，最新在前；过了 `expiresAt` 的 pending 读出来是 `expired`（当场计算，后台没有定时任务）。其他 `state` 是 422 `validation_failed`。最多 100 条。

不返回：备注、预约 id、课程 id、课包 id、会员编号、谁发起、谁决定、拒绝原因、核销 id。

- 认证：`bearer`
- operationId：`listMeRedemptionRequests`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `state` | query | string |  | 默认 pending（未过期）。all 是最近 30 天，含计算出来的 expired<br>可选值：`pending`、`all` |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {requests: [MeRedemptionRequest]} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `requests` | MeRedemptionRequest[] | 是 |  |
| `requests[].id` | string | 是 |  |
| `requests[].memberId` | string | 是 |  |
| `requests[].memberName` | string | 是 |  |
| `requests[].courseName` | string |  | 课程没有名字则省略 |
| `requests[].times` | integer | 是 |  |
| `requests[].state` | string | 是 | expired 是读的时候算的<br>可选值：`pending`、`confirmed`、`declined`、`cancelled`、`expired` |
| `requests[].expiresAt` | string(date-time) | 是 |  |
| `requests[].createdAt` | string(date-time) | 是 |  |

### `POST /me/redemption-requests/{id}/confirm`

确认前台的核销申请；次数只在此刻扣，记在发起的员工名下

需要范围 `membership.redemption:confirm`。写操作（10 分钟后 `assertion_stale`）。没有正文。

`{id}` 必须是这位客户的申请，否则 404。只有仍 `pending`（且未过期）能确认；否则 409 `conflict`。第二次确认也是 409，不会把第一次的 JSON 再给一遍。

扣次就是前台自己的核销，同一事务，幂等键 `rr:<申请 id>`，核销记在发起员工名下。200 是 `{confirmed: true, remaining, notices}`。`remaining` 是扣完后的课包余额；期卡扣 0，所以这不是到店次数。`notices` 可能为空。

与前台核销相同的拒绝是 422 `validation_failed`（候补、已取消、缺席、课次取消、不是上课当天、超过 7 天、会员冻结/过期/关闭、课包不可用、剩余次数不足）。

- 认证：`bearer`
- operationId：`confirmMeRedemption`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | path | string | 是 | 核销申请 id |
| `Idempotency-Key` | header | string |  | 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答<br>最短 1，最长 255 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {confirmed: true, remaining, notices} |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 409 | 见 [Conflict](#resp-conflict) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `confirmed` | boolean | 是 | 固定为 `true` |
| `remaining` | integer | 是 | 扣完后的课包余额。期卡扣 0 |
| `notices` | string[] | 是 | 可能为空。否则是用完、只剩 N 次、今天到期、N 天后到期 |

### `POST /me/redemption-requests/{id}/decline`

拒绝前台的核销申请；不扣次

需要范围 `membership.redemption:confirm`。写操作（10 分钟后 `assertion_stale`）。

正文可选 `{reason}`，最多 200 字，否则 422 `validation_failed`。可以不带正文。404 / 409 与确认相同（只有仍 pending 的申请）。不扣次。

- 认证：`bearer`
- operationId：`declineMeRedemption`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `id` | path | string | 是 | 核销申请 id |
| `Idempotency-Key` | header | string |  | 除 /oauth/token 外每个 POST 都收。SDK 未传入时会生成 uuid v4。24 小时内同键同正文重放第一次的回答<br>最短 1，最长 255 |

**请求正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `reason` | string |  | 最多 200 字 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | {declined: true} |
| 400 | `invalid_request`：查询或正文读不出来。见 [BadRequest](#resp-badrequest) |
| 401 | 见 [Unauthorized](#resp-unauthorized) |
| 403 | 见 [Forbidden](#resp-forbidden) |
| 404 | `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。见 [NotFound](#resp-notfound) |
| 409 | 见 [Conflict](#resp-conflict) |
| 413 | `payload_too_large`：正文太大。见 [PayloadTooLarge](#resp-payloadtoolarge) |
| 422 | 见 [Unprocessable](#resp-unprocessable) |
| 429 | 见 [RateLimited](#resp-ratelimited) |
| 500 | `internal`：平台出错。见 [Internal](#resp-internal) |
| 503 | `unavailable`：暂时不可用（读凭证、读授权、读库失败）。见 [Unavailable](#resp-unavailable) |

**200 响应正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `declined` | boolean | 是 | 固定为 `true` |

## 事件（Webhooks）

平台向你登记的 https 地址推送的事件。每个事件是一个 `POST` 请求。

### `app.installed`

一个组织装上了这个应用

只发给这个应用自己的开发者订阅，不需要范围。`data` 是 `{version, scopes}`。信封带 `tenant`（正式环境也会带：租户装了你的应用，你要用这个 id 去换该组织的令牌）、`environment`、`app`。
用 `X-Kwork-Signature: t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>` 验签，拒绝过期的时间戳。另有 `X-Kwork-Event`、`X-Kwork-Delivery`。

- operationId：`receiveAppInstalled`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"app.installed"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | AppInstalledData | 是 |  |
| `data.version` | string | 是 |  |
| `data.scopes` | string[] | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下。非 2xx 会按退避重投 |

### `app.uninstalled`

一个组织卸载了这个应用

只发给这个应用自己的开发者订阅，不需要范围。`data` 是 `{}`。验签同 app.installed。

- operationId：`receiveAppUninstalled`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"app.uninstalled"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | AppUninstalledData | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `app.upgraded`

一个组织把这个应用升到了新版本

只发给这个应用自己的开发者订阅。`data` 是 `{from, to, scopes}`。

- operationId：`receiveAppUpgraded`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"app.upgraded"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | AppUpgradedData | 是 |  |
| `data.from` | string | 是 |  |
| `data.to` | string | 是 |  |
| `data.scopes` | string[] | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `app.scopes_changed`

管理员改了同意给这个应用的权限范围

只发给这个应用自己的开发者订阅。`data` 是 `{scopes, added, removed}`。

- operationId：`receiveAppScopesChanged`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"app.scopes_changed"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | AppScopesChangedData | 是 |  |
| `data.scopes` | string[] | 是 |  |
| `data.added` | string[] | 是 |  |
| `data.removed` | string[] | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `dev.version.approved`

平台审核通过了一个版本，可以上架了

开发者订阅，不需要租户授予的范围。`data` 是 `{app, version, decision}`，`decision` 为 `approved`。同一审核结果的 id 在每次重试、每个订阅上都相同，接收方按 id 去重。

- operationId：`receiveDevVersionApproved`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"dev.version.approved"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | DevVersionData | 是 |  |
| `data.app` | string | 是 |  |
| `data.version` | string | 是 |  |
| `data.decision` | string | 是 | 可选值：`approved`、`rejected` |
| `data.reason` | string |  | 驳回时的审核意见 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `dev.version.rejected`

平台审核驳回了一个版本

开发者订阅。`data` 是 `{app, version, decision, reason?}`，`decision` 为 `rejected`，`reason` 是审核意见（改好后重新提交）。

- operationId：`receiveDevVersionRejected`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"dev.version.rejected"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | DevVersionData | 是 |  |
| `data.app` | string | 是 |  |
| `data.version` | string | 是 |  |
| `data.decision` | string | 是 | 可选值：`approved`、`rejected` |
| `data.reason` | string |  | 驳回时的审核意见 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `membership.redeem_request.created`

前台请客户确认一次核销

需要范围 `membership.redemption:confirm`。租户集成订阅与商店应用的开发者订阅（按安装且授予了该范围的组织）都会收到。
`data` 含 `customer`（客户中心 id）、`member`、`request`、`course`（课程名）、`times`、`expiresAt`。没有电话、没有姓名。客户该去小程序里确认。

- operationId：`receiveMembershipRedeemRequestCreated`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"membership.redeem_request.created"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | RedeemRequestCreatedData | 是 |  |
| `data.customer` | string | 是 | 客户中心 id |
| `data.member` | string | 是 |  |
| `data.request` | string | 是 |  |
| `data.course` | string |  | 课程名 |
| `data.times` | integer | 是 |  |
| `data.expiresAt` | string(date-time) |  |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `membership.redeem_request.cancelled`

前台取消了请客户确认的核销

需要范围 `membership.redemption:confirm`。`data` 含 `customer`、`member`、`request`。客户不用再确认。

- operationId：`receiveMembershipRedeemRequestCancelled`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"membership.redeem_request.cancelled"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | RedeemRequestCancelledData | 是 |  |
| `data.customer` | string | 是 |  |
| `data.member` | string | 是 |  |
| `data.request` | string | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `membership.booking.created`

客户有了新的预约或候补

需要范围 `membership.me:read`。前台或客户自己预约都会发。`data` 含 `customer`、`member`、`booking`、`session`、`course`（课程名）、`startsAt`、`status`（booked 或 waitlist）。

- operationId：`receiveMembershipBookingCreated`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"membership.booking.created"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | BookingCreatedData | 是 |  |
| `data.customer` | string | 是 |  |
| `data.member` | string | 是 |  |
| `data.booking` | string | 是 |  |
| `data.session` | string | 是 |  |
| `data.course` | string | 是 | 课程名 |
| `data.startsAt` | string(date-time) | 是 |  |
| `data.status` | string | 是 | booked 或 waitlist |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `membership.booking.cancelled`

客户的预约或候补被取消

需要范围 `membership.me:read`。前台、客户自己或课次取消都会发。`data` 含 `customer`、`member`、`booking`、`session`、`course`、`charged`（实际扣次）。

- operationId：`receiveMembershipBookingCancelled`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"membership.booking.cancelled"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | BookingCancelledData | 是 |  |
| `data.customer` | string | 是 |  |
| `data.member` | string | 是 |  |
| `data.booking` | string | 是 |  |
| `data.session` | string | 是 |  |
| `data.course` | string | 是 |  |
| `data.charged` | integer | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `membership.redemption.created`

客户有一次核销（到店核销，或客户确认了申请）

需要范围 `membership.me:read`。`data` 含 `customer`、`member`、`redemption`、`course`、`times`、`remaining`（扣完后的课包余额）。

- operationId：`receiveMembershipRedemptionCreated`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"membership.redemption.created"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | RedemptionCreatedData | 是 |  |
| `data.customer` | string | 是 |  |
| `data.member` | string | 是 |  |
| `data.redemption` | string | 是 |  |
| `data.course` | string |  |  |
| `data.times` | integer | 是 |  |
| `data.remaining` | integer | 是 |  |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `customer.created`

组织里新建了一位客户

需要范围 `customer:read`。本批只在开放接口 POST /customers 新建时发出（员工在客户应用里新建不走这条）。
`data` 是 `{customer, id}`（同一客户中心 id），没有电话、没有姓名。

- operationId：`receiveCustomerCreated`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"customer.created"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | CustomerCreatedData | 是 |  |
| `data.customer` | string | 是 | 客户中心 id |
| `data.id` | string | 是 | 与 customer 相同 |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

### `customer.updated`

一位客户的姓名或标签改了

需要范围 `customer:read`。本批只在开放接口 PATCH /customers/{id} 改了字段时发出。
`data` 是 `{customer, id, fields}`，`fields` 为 `name`、`tags` 中实际改过的项。没有电话、没有姓名。

- operationId：`receiveCustomerUpdated`

**参数**

| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
| `X-Kwork-Signature` | header | string | 是 | t=&lt;unix>,v1=&lt;hex HMAC-SHA256(secret, "&lt;t>.&lt;body>")> |
| `X-Kwork-Event` | header | string | 是 | 事件类型，与正文 type 相同 |
| `X-Kwork-Delivery` | header | string | 是 | 这一次投递的 id（重投会变；事件 id 在正文里，重试不变） |

**请求正文（必填）**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `id` | string | 是 | evt_…，同一事件每次重试、每个订阅都相同，按它去重 |
| `type` | string | 是 | 固定为 `"customer.updated"` |
| `createdAt` | string(date-time) | 是 |  |
| `apiVersion` | string | 是 | 固定为 `"2026-10-28"` |
| `tenant` | string |  | 租户范围的事件有。发给商店应用的正式事件也会带（租户装了你的应用） |
| `environment` | string |  | 可选值：`test`、`live` |
| `app` | string |  |  |
| `data` | CustomerUpdatedData | 是 |  |
| `data.customer` | string | 是 | 客户中心 id |
| `data.id` | string | 是 | 与 customer 相同 |
| `data.fields` | string[] | 是 | 实际改过的字段，name 和/或 tags} |

**响应**

| 状态 | 说明 |
|---|---|
| 200 | 接收方已收下 |

## 通用响应

多个接口共用的响应。

### BadRequest

400 `invalid_request`：查询或正文读不出来。换令牌时还有 `unsupported_grant_type`（不认识的 grant_type）和 `tenant_required`（应用级凭证必须带 tenant；或带了但不是这个凭证能用的组织）。多维表筛/排了视图里没有的字段也是这一条。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Conflict

409。`conflict`：和当前状态冲突（已经预约、申请已经处理、晚取消未带 acceptCharge）。`ambiguous_customer`：同一号码对应多个客户，`details.candidates` 列出 id，下次带上 `customer_id`。`idempotency_conflict`：同一 Idempotency-Key 不同正文。`idempotency_in_progress`：第一次还在处理，稍后再读或换新键。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Forbidden

403。`scope_required`：缺了操作描述里写的范围，去开发者平台或请管理员勾。`customer_token_required`：/me 只要客户令牌，用客户授权再调。`wrong_environment`：测试密钥打了正式组织，或正式密钥打了测试企业。`not_installed`：应用没装、关掉，或组织停用。`no_scope`：一把范围都没授。`address_not_allowed`：来源 IP 不在凭证允许名单。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Internal

500 `internal`：平台出错。带上 `X-Request-Id` 报给我们。不要立刻换一种写法重试同一把幂等键。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### InvalidClient

401 `invalid_client`：client_id 不存在或 client_secret 不对。两种情况同一句，避免探测。带 `WWW-Authenticate: Basic`。密钥已吊销或过期是 `credential_ended`（也是 401）。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### NotFound

404 `not_found`：不存在，或你不该知道它存在（别人的会员、未开放的表、进不了的视图）。不要靠 403 去区分「没有」和「没权限」。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### NotImplemented

501 `not_implemented`。客户授权在这个部署不可用时会出现（代码仍可能是 `unavailable`）。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### PayloadTooLarge

413 `payload_too_large`：正文太大。会员写接口上限 1 MiB。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### RateLimited

429。`rate_limited`：这把凭证的调用预算用完，看 `Retry-After` 和 `X-RateLimit-*`。`too_many_attempts`：这个地址失败太多次，等 `Retry-After`（换令牌时常见 60 秒）。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Unauthorized

401。`token_required`：没带 Bearer，先 POST /oauth/token。`invalid_token`：令牌无效或过期，重新换（SDK 会自动换一次再重试）。`credential_ended`：凭证吊销、过期，或应用关掉、范围被收光。写 /me 时还有 `assertion_stale`：客户断言超过 10 分钟，重新断言再写。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Unavailable

503 `unavailable`：暂时不可用（读凭证、读授权、读库失败）。按 `Retry-After` 重试；没有该头时 SDK 用指数退避。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |

### Unprocessable

422。`validation_failed`：字段或门店规则不接受（日期窗口、课次不存在、会员状态、剩余次数不足、原因超长）。换客户令牌时电话读不出来是 `invalid_phone`：带国家码，或按大陆手机号发。

**正文**（`application/json`）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `error` | ErrorBody | 是 |  |
| `error.code` | string | 是 | 稳定错误码。用它分支，不要解析 message。<br>可选值：`invalid_request`、`invalid_grant`、`consent_required`、`consent_revoked`、`person_gone`、`unauthorized`、`forbidden`、`not_found`、`method_not_allowed`、`conflict`、`payload_too_large`、`validation_failed`、`rate_limited`、`internal`、`not_implemented`、`unavailable`、`token_required`、`invalid_token`、`invalid_credential`、`invalid_client`、`unsupported_grant_type`、`tenant_required`、`wrong_environment`、`credential_ended`、`address_not_allowed`、`not_installed`、`no_scope`、`scope_required`、`customer_token_required`、`assertion_stale`、`invalid_phone`、`ambiguous_customer`、`too_many_attempts`、`idempotency_conflict`、`idempotency_in_progress`、`condition_failed` |
| `error.message` | string | 是 | 给人看的说明，并写出下一步 |
| `error.requestId` | string |  | 与 X-Request-Id 相同 |
| `error.docUrl` | string |  | 这条错误的文档，形如 https://dev.{domain}/docs/errors#scope_required |
| `error.details` | object |  | 没有内容时是 {}。ambiguous_customer 时含 candidates（客户 id 列表） |
