Keila 0.30.3 API 使用详解

Keila 0.30.3 REST API 实测详解:Bearer 认证与项目绑定、data 信封约定、分页与过滤、联系人 CRUD、Campaign 发送、事务性邮件与 Liquid 渲染预览、OpenAPI 规范。

KeilaKeila邮件服务邮件列表PostgreSQL2026/10/26

Keila 0.30.3 API 使用详解

一、接入前提

Keila 0.30.3 内置一套 REST API(挂在 /api/v1 下),覆盖联系人、Campaign、表单、分群、模板、单发消息等资源,适合把 Keila 接进既有业务系统:CRM 同步联系人、事务性邮件(订单确认、验证码)直接经 API 单发、定时任务批量投递等。

1.1 创建 API Key

API Key 在 Web 界面的项目设置页创建:Settings → API Keys → Create API Key,起一个名字提交后,界面会展示密钥并提示「The private API Key is displayed only once and cannot be recovered」——密钥只显示一次,关闭页面后无法再查看,只能重新生成。

密钥与项目绑定:持有某项目的 API Key 只能操作该项目下的资源。这在服务端的认证插件的实现非常直接——密钥记录里存着 project_id,请求进来时按密钥解析出项目并校验归属,失败即 403。

实测环境(部署过程见《Debian 12 安装 Keila 0.30.3 指南》):项目 ID np_weLJnLY5,密钥 lkp0Henf2NsXM8Vehf4R6zKdAE23eGzSRB-ftkwqTJQ(实验口令,生产请妥善保管)。

1.2 认证方式

所有请求带 Authorization: Bearer <api_key> 头。不带或密钥错误返回 403:

curl -s -w '\nHTTP %{http_code}\n' -X POST -H "Content-Type: application/json" \
  -d '{"data":{"email":"x@y.z"}}' http://192.168.146.131:8139/api/v1/contacts

实测回显:

{"errors":[{"status":"403","title":"Not authorized"}]}
HTTP 403

二、请求约定

四个容易踩坑的约定,全部实测得出:

1. 请求体必须用 data 键包裹业务字段。 Keila 的 API schema(OpenApiSpex 实现)按 {"data": {...}} 校验,平铺字段会解析为空对象进而报错:

// 正确
{"data":{"email":"alice@example.com"}}

// 错误(返回 400 "Bad Request")
{"email":"alice@example.com"}

2. 分页是 deepObject 风格查询参数(paginate[page] / paginate[page_size]),不是单个 page 参数——直接传 ?page=1 会得到 400 Unexpected field:

GET /api/v1/contacts?paginate[page]=0&paginate[page_size]=1

实测回显(page 从 0 起):

meta: {'count': 1, 'page': 0, 'page_count': 1} | ids: ['nc_weLJnLY5']

列表响应统一带 meta 对象(count 本页条数、page 当前页、page_count 总页数)。

3. 列表过滤用 filter 参数,值为 JSON 字符串,操作符是 MongoDB 风格($like、$eq 等):

curl -s -G -H "Authorization: Bearer $KEY" \
  --data-urlencode 'filter={"email":{"$like":"%example.com"}}' \
  http://192.168.146.131:8139/api/v1/contacts

实测回显:

meta: {'count': 1, 'page': 0, 'page_count': 1} | emails: ['alice@example.com']

4. 单资源寻址支持三种 ID,路径上的值默认按 Keila ID(nc_/nmc_ 等前缀)解析;用邮箱或 external_id 时必须带 ?id_type=email / ?id_type=external_id:

GET /api/v1/contacts/nc_weLJnLY5                     → 200
GET /api/v1/contacts/alice@example.com?id_type=email → 200
GET /api/v1/contacts/alice@example.com               → 400(缺 id_type)

实测回显(邮箱寻址):

{"data":{"data":{"plan":"pro"},"id":"nc_weLJnLY5","status":"active","email":"alice@example.com","inserted_at":"2026-10-26T06:29:39Z","updated_at":"2026-10-26T06:54:19Z","external_id":null,"first_name":"Alice","last_name":"Zhou"}}
HTTP 200

三、端点总览

写作时实测的 OpenAPI 规范(见下文)共 17 个路径,按资源域整理:

资源域方法说明
/api/v1/contactsGET / POST联系人列表(分页+过滤)/ 创建
/api/v1/contacts/{id}GET / PATCH / DELETE查询 / 更新 / 删除
/api/v1/contacts/{id}/dataPATCH / POST自定义字段浅合并 / 整体替换
/api/v1/campaignsGET / POSTCampaign 列表 / 创建
/api/v1/campaigns/{id}GET / PATCH / DELETE查询 / 更新 / 删除
/api/v1/campaigns/{id}/actions/sendPOST触发投递(异步)
/api/v1/campaigns/{id}/actions/schedulePOST定时投递
/api/v1/formsGET / POST + 单资源表单 CRUD
/api/v1/forms/{id}/actions/submitPOST以 API 身份提交表单
/api/v1/segmentsGET / POST + 单资源分群 CRUD
/api/v1/templatesGET / POST + 单资源模板 CRUD
/api/v1/sendersGET发件人只读列表(创建走 Web 界面)
/api/v1/messagesPOST单发事务性邮件
/api/v1/messages/actions/renderPOST渲染预览(不发送)
/api/v1/openapiGETOpenAPI 3.0.0 规范全文

两个设计要点:senders 没有创建/删除端点(发件人涉及 SMTP 凭据,官方只在 Web 界面开放管理,API 可列出用于引用其 sender_id);单发消息没有查询端点(发送即走 Oban 队列,结果以投递为准)。

四、联系人全流程实测

4.1 创建与更新

curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"data":{"email":"alice@example.com","first_name":"Alice","last_name":"Chen"}}' \
  http://192.168.146.131:8139/api/v1/contacts

实测回显:

{"data":{"data":null,"id":"nc_weLJnLY5","status":"active","email":"alice@example.com","inserted_at":"2026-10-26T06:29:39Z","updated_at":"2026-10-26T06:29:39Z","external_id":null,"first_name":"Alice","last_name":"Chen"}}

创建即激活(status: "active",API 创建不走邮件激活流程)。PATCH 更新单个字段:

curl -s -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"data":{"last_name":"Zhou"}}' \
  http://192.168.146.131:8139/api/v1/contacts/nc_weLJnLY5

实测回显(updated_at 变为 06:54:10Z):

{"data":{"data":null,"id":"nc_weLJnLY5","status":"active","email":"alice@example.com","inserted_at":"2026-10-26T06:29:39Z","updated_at":"2026-10-26T06:54:10Z","external_id":null,"first_name":"Alice","last_name":"Zhou"}}

4.2 自定义字段(data)

联系人的 data 字段是自由 JSON(也可在 Web 界面的联系人表单里预定义字段),data 子端点提供两种语义:

  • PATCH = 浅合并:保留已有键,覆盖同名键;
  • POST = 整体替换。
curl -s -X PATCH -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"data":{"plan":"pro"}}' \
  http://192.168.146.131:8139/api/v1/contacts/nc_weLJnLY5/data

实测回显(data 从 null 变为 {"plan":"pro"}):

{"data":{"data":{"plan":"pro"},"id":"nc_weLJnLY5","status":"active","email":"alice@example.com","inserted_at":"2026-10-26T06:29:39Z","updated_at":"2026-10-26T06:54:19Z","external_id":null,"first_name":"Alice","last_name":"Zhou"}}

4.3 删除

curl -s -w '\nHTTP %{http_code}\n' -X DELETE -H "Authorization: Bearer $KEY" \
  http://192.168.146.131:8139/api/v1/contacts/nc_BzLMaLXv
curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $KEY" \
  http://192.168.146.131:8139/api/v1/contacts/nc_BzLMaLXv

实测回显(删除是幂等的,之后查询返回 404):

HTTP 204
{"errors":[{"status":"404","title":"Not found"}]}
HTTP 404

五、Campaign 创建与发送

Campaign 的内容类型由 settings.type 决定,必须与内容字段匹配(text → text_body,html → html_body,mjml → mjml_body):

curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"data":{"subject":"Keila B1 验证邮件","text_body":"Hello from Keila 0.30.3 built from source!","sender_id":"nms_weLJnLY5","settings":{"type":"text"}}}' \
  http://192.168.146.131:8139/api/v1/campaigns

实测回显(节选):

{"data":{"data":null,"id":"nmc_weLJnLY5","subject":"Keila B1 验证邮件","settings":{"type":"text"},"inserted_at":"2026-10-26T06:32:39Z","text_body":"Hello from Keila 0.30.3 built from source!","sender_id":"nms_weLJnLY5","sent_at":null,"segment_id":null}}

触发投递(异步——进入 Oban 队列后立即返回,逐个联系人渲染并经 SMTP 发出):

curl -s -i -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  http://192.168.146.131:8139/api/v1/campaigns/nmc_weLJnLY5/actions/send

实测回显:

HTTP/1.1 202 Accepted
content-type: application/json; charset=utf-8

{"data":{"campaign_id":"nmc_weLJnLY5","delivery_queued":true}}

投递范围语义:segment_id 为 null 时发送给项目内全部 active 联系人;指定 Segment 则只发给分群命中的联系人。发送完成后列表中可见 sent_at 时间戳:

GET /api/v1/campaigns →
{"data":[{"id":"nmc_weLJnLY5",...,"sent_at":"2026-10-26T06:32:44Z",...}],"meta":{"count":1,"page":0,"page_count":1}}

六、事务性邮件(单发)

POST /api/v1/messages 用于业务系统直接投递一封邮件(验证码、订单通知等),必填字段为 type、sender_id 与收件人(recipient_email / contact_id / external_contact_id 三选一):

curl -s -w '\nHTTP %{http_code}\n' -X POST -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"data":{"type":"text","sender_id":"nms_weLJnLY5","recipient_email":"alice@example.com","subject":"API 单发测试","text_body":"Sent via POST /api/v1/messages"}}' \
  http://192.168.146.131:8139/api/v1/messages

实测回显:

{"data":{"id":"nmr_2yLDNVbQ","recipient_email":"alice@example.com","subject":"API 单发测试"}}
HTTP 200

在收信端确认送达(实验环境的收信端为 Mailpit,SMTP 通道配置见安装指南):

total: 3
newsletter@keila.local -> ['alice@example.com'] | API 单发测试
newsletter@keila.local -> ['admin@keila.local'] | Your Account Reset Link
newsletter@keila.local -> ['admin@keila.local'] | Your Account Reset Link

字段名与 Campaign 不同:单发邮件的收件人字段是 recipient_email(Campaign 无收件人字段,投递范围由 segment 决定)。传错字段名会得到精确到字段的错误提示,例如传 recipient 时返回 {"errors":[{"pointer":"/data/recipient","status":"400","title":"Unexpected field"}]}。

七、Liquid 渲染与预览

POST /api/v1/messages/actions/render 对内容做 Liquid 插值渲染但不发送,用于预览或本地测试。必填:type、sender_id、收件人(渲染要取联系人数据)、subject。Liquid 上下文里可访问 contact.*(含自定义字段 contact.data.*):

curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"data":{"type":"text","sender_id":"nms_weLJnLY5","recipient_email":"alice@example.com","subject":"渲染测试","text_body":"Hi {{ contact.first_name }}! 你的套餐是 {{ contact.data.plan }}。"}}' \
  http://192.168.146.131:8139/api/v1/messages/actions/render

实测回显(联系人 Alice 的自定义字段 plan=pro 被正确插值):

{"data":{"html_body":null,"subject":"渲染测试","text_body":"Hi Alice! 你的套餐是 pro。"}}

漏字段时的错误同样精确:缺收件人返回 {"errors":[{"detail":"one of contact_id, external_contact_id, recipient_email is required",...}]},缺主题返回 {"errors":[{"status":"400","title":"No subject"}]}。

八、OpenAPI 规范端点

Keila 自带机器可读规范,便于生成各语言客户端:

curl -s -o openapi.json -w 'HTTP %{http_code}, %{size_download} bytes\n' \
  -H "Authorization: Bearer $KEY" http://192.168.146.131:8139/api/v1/openapi
python3 -c "import json; d=json.load(open('openapi.json')); print('openapi:', d.get('openapi'), '| paths:', len(d.get('paths',{})))"

实测回显:

HTTP 200, 58319 bytes
openapi: 3.0.0 | paths: 17

九、常见问题

9.1 一直 403 "Not authorized"

检查三点:请求头是否为 Authorization: Bearer <key>(Bearer 后有一个空格);密钥是否属于目标项目(密钥绑定创建时的项目,跨项目资源一律 403);密钥是否完整(复制时截断)。

9.2 POST/PUT 返回 400 "Bad Request" 但没有细节

十有八九是没用 {"data": {...}} 信封。Keila 对信封外的平铺字段不做合并,解析结果为空时直接 400。修正后若仍有问题,会得到带 pointer 的字段级错误(如 Unexpected field、Missing field)。

9.3 分页传 page 参数报 "Unexpected field"

列表分页参数是 deepObject 风格的 paginate[page] 与 paginate[page_size],且 page 从 0 开始;单个 page=1 不被接受。

9.4 用邮箱查询联系人返回 400

路径值默认按 Keila ID 解析,邮箱/external_id 寻址必须加 ?id_type=email(或 id_type=external_id)。

9.5 API 无法创建发件人

设计如此:senders 域只有 GET /api/v1/senders 只读列表,创建/编辑发件人(含 SMTP 中继凭据)只能在 Web 界面完成。集成时先在界面建好,再从列表取 sender_id 供 Campaign 与单发邮件引用。

9.6 render 端点报 "No subject" 或要求 contact_id

渲染是"以某联系人的视角"做插值,所以收件人(三选一)与 subject 都是必填,不只是传个 body 模板。只想本地调试 Liquid 的话,同样可以用该端点返回的 text_body/html_body 比对预期。

十、参考链接