11 KiB
OpenAPI Notes
本文件描述 Send Engine 對外 API、Webhook 驗證與與 Member Center 的介接規則。 目標是讓 Member Center 與租戶網站可以清楚交換資料與責任邊界。
Auth 與驗證
1. 租戶網站 → Send Engine API
使用 OAuth2 Client Credentials(token 由 Member Center 簽發,Send Engine 以 OIDC/JWKS 驗簽驗證)。
若未明確設定 JWT metadata/authority,會回退使用 MemberCenter__BaseUrl + /.well-known/openid-configuration。
必要 claims:
tenant_idscope(至少newsletter:send.write)- 必須包含
aud(需符合Jwt__Audience)
規則:
tenant_id只能取自 token,不接受 body 覆寫list_id必須屬於該 tenant
2. Member Center → Send Engine Webhook
目前實作為簽名 Webhook(HMAC)。
Header 建議:
X-Signature:hex(hmac_sha256(secret, body))X-Timestamp: Unix epoch secondsX-Nonce: UUIDX-Client-Id: Auth client UUID(對應auth_clients.id)
驗證規則:
- timestamp 在允許時間窗內(例如 ±5 分鐘)
- nonce 不可重複(重放防護)
- signature 必須匹配
- client 必須存在且為 active
auth_clients.tenant_id必須與 payloadtenant_id一致- 不使用任何
X-Client-Idfallback;缺少 tenant 對應 client 時應由來源端跳過發送 - 預設拒絕
auth_clients.tenant_id = NULL的通用 client(可透過Webhook__AllowNullTenantClient=true明確開啟)
3. Send Engine → Member Center 回寫
使用 OAuth2 Client Credentials(Send Engine 作為 client)
scope 最小化:
newsletter:events.write(停用回寫)newsletter:list.read(若未來仍需查詢)
實作約定:
- 優先走 token endpoint(client credentials)
ApiToken僅作暫時 fallback(建議逐步移除)
通用欄位
Timestamp
- 欄位:
occurred_at - 格式:RFC3339,例如
2026-02-10T09:30:00Z
ID
tenant_id、list_id、subscriber_id、event_id皆為 UUID
通用錯誤格式
{
"error": "string_code"
}
補充:
- 部分錯誤會附帶
reason或message(例如 webhook 驗證失敗) message、request_id目前非固定欄位
Webhook:Member Center → Send Engine
A. 訂閱事件(增量)
用途:同步新增/取消/偏好變更。
Endpoint:
POST /webhooks/subscriptions
Scope:
newsletter:events.write
事件型別:
subscription.activatedsubscription.unsubscribedpreferences.updated
Request Body(示意):
{
"event_id": "uuid",
"event_type": "subscription.activated",
"tenant_id": "uuid",
"list_id": "uuid",
"subscriber": {
"id": "uuid",
"email": "user@example.com",
"status": "active",
"preferences": { "topic": "news" }
},
"occurred_at": "2026-02-10T09:30:00Z"
}
Response:
200 OK:accepted401/403:驗證失敗409:event_id 重複或 nonce 重放422:格式錯誤
B. 全量名單同步
用途:由 Member Center 主動推送全量或分批名單,避免 Send Engine 拉取名單。
Endpoint:
POST /webhooks/lists/full-sync
Scope:
newsletter:events.write
Request Body(分批示意):
{
"sync_id": "uuid",
"batch_no": 1,
"batch_total": 10,
"tenant_id": "uuid",
"list_id": "uuid",
"subscribers": [
{ "id": "uuid", "email": "a@example.com", "status": "active" },
{ "id": "uuid", "email": "b@example.com", "status": "unsubscribed" }
],
"occurred_at": "2026-02-10T09:30:00Z"
}
Response:
200 OK:accepted401/403:驗證失敗422:格式錯誤
API:租戶網站 → Send Engine
C. 建立 Send Job
用途:租戶網站送入內容,建立 Campaign/Send Job 並排程。
Endpoint:
POST /api/send-jobs
Scope:
newsletter:send.write
Request Body:
{
"tenant_id": "uuid",
"list_id": "uuid",
"name": "Weekly Update",
"subject": "Weekly Update",
"body_html": "<p>Hello</p>",
"body_text": "Hello",
"template": null,
"scheduled_at": "2026-02-11T02:00:00Z",
"window_start": "2026-02-11T02:00:00Z",
"window_end": "2026-02-11T05:00:00Z",
"tracking": { "open": true, "click": false }
}
欄位規則:
subject必填,最小長度 1body_html/body_text/template至少擇一window_start必須小於window_end(若有提供)tenant_id必須已存在(不存在回422 tenant_not_found)list_id若不存在,會在該 tenant 下自動建立 placeholder list 後建立 send jobtemplate可攜帶發信元資料(例如:{"ses_template_name":"newsletter_default","list_unsubscribe_url_template":"https://member.example/unsubscribe?token={{unsubscribe_token}}","list_unsubscribe_mailto":"mailto:unsubscribe@member.example"})
替換合約(Mock/SES 一致):
{{email}}{{unsubscribe_token}}{{tenant_id}}{{list_id}}{{campaign_id}}{{send_job_id}}
Response:
{
"send_job_id": "uuid",
"sendJobId": "uuid",
"status": "pending"
}
說明:回應同時提供 snake_case 與 camelCase(向後相容不同語言客戶端)。
C-1. Tenant Site Integration(已實作)
用途:內容網站以 Member Center 發出的 JWT 呼叫 Send Engine 建立發送工作。
步驟:
- 取得 access token(scope 至少
newsletter:send.write) - 呼叫
POST /api/send-jobs - 查詢
GET /api/send-jobs/{id}(需newsletter:send.read)
token 需包含:
tenant_id(UUID)scope(空白分隔字串)
範例(建立 send job):
curl -X POST "http://localhost:6060/api/send-jobs" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "c9034414-43d6-404e-8d41-e80922420bf1",
"list_id": "22222222-2222-2222-2222-222222222222",
"name": "Weekly Update",
"subject": "Hi {{email}}",
"body_html": "<p>Hello {{email}}</p><p><a href=\"https://member.example/unsubscribe?token={{unsubscribe_token}}\">unsubscribe</a></p>",
"body_text": "Hello {{email}} | unsubscribe: https://member.example/unsubscribe?token={{unsubscribe_token}}",
"template": {
"ses_template_name": "newsletter_default"
}
}'
回應:
{
"send_job_id": "uuid",
"sendJobId": "uuid",
"status": "pending"
}
說明:
ESP__Provider=mock(非 ses)時,會由 Dev Sender 產生send.preview事件供你檢查替換結果ESP__Provider=mock時,也會把每位收件人的模擬發送內容輸出到 console log(MOCK send preview)- 若
template.list_unsubscribe_url_template(或template.list_unsubscribe_mailto)有提供,sender 會加上: List-UnsubscribeList-Unsubscribe-Post: List-Unsubscribe=One-ClickESP__Provider=ses時,背景 sender 依Ses__SendMode發送:raw_bulk(預設):SESSendEmail,依內容分組每次最多 50 位收件者bulk_template:SESSendBulkEmail(每批最多 50,需要 SES template)- 若已設定 Member Center one-click token endpoint,發送前會批次呼叫
POST /newsletter/one-click-unsubscribe-tokens - 僅
status=issued的收件者會被送出,並把unsubscribe_token注入替換內容
D. 查詢 Send Job
Endpoint:
GET /api/send-jobs/{id}
Scope:
newsletter:send.read
Response:
{
"id": "uuid",
"tenant_id": "uuid",
"tenantId": "uuid",
"list_id": "uuid",
"listId": "uuid",
"campaign_id": "uuid",
"campaignId": "uuid",
"status": "running",
"scheduled_at": "2026-02-11T02:00:00Z",
"scheduledAt": "2026-02-11T02:00:00Z",
"window_start": "2026-02-11T02:00:00Z",
"windowStart": "2026-02-11T02:00:00Z",
"window_end": "2026-02-11T05:00:00Z",
"windowEnd": "2026-02-11T05:00:00Z"
}
E. 取消 Send Job
Endpoint:
POST /api/send-jobs/{id}/cancel
Scope:
newsletter:send.write
Response:
{
"id": "uuid",
"status": "cancelled"
}
Webhook:SES → Send Engine
F. SES 事件回報
用途:接收 bounce/hard_bounced/soft_bounced/complaint/suppression/delivery/open/click 等事件。
推薦架構(正式):
SES Configuration Set -> SNS -> SQS -> ECS Worker- 由 Worker 消費事件,不要求對外公開 webhook
- 目前實作:
SqsSesPollerWorker會從 SQS 取訊息並直接呼叫內部 SES processing service
相容模式(可選):
POST /webhooks/ses
驗證:
- 目前實作:
Ses__SkipSignatureValidation=false時會驗 SNS envelope 簽章(SigningCertURL、SignatureVersion、Signature、canonical string) - 可加強:在環境設定
Ses__AllowedTopicArns與Ses__AllowedCertHosts做來源白名單
Request Body(示意):
{
"Type": "Notification",
"MessageId": "sns-message-id",
"Message": "{\"eventType\":\"Bounce\",\"mail\":{\"messageId\":\"ses-message-id\",\"tags\":{\"tenant_id\":[\"...\"],\"list_id\":[\"...\"]}},\"bounce\":{\"bounceType\":\"Permanent\",\"bouncedRecipients\":[{\"emailAddress\":\"user@example.com\"}],\"timestamp\":\"2026-02-10T09:45:00Z\"}}",
"Timestamp": "2026-02-10T09:45:01Z"
}
相容:仍接受舊版扁平 payload(event_type/tenant_id/email)。
Response:
200 OK422:payload 解析錯誤或缺少必要欄位500:處理時發生未預期錯誤(SQS poller 會視為 transient,保留訊息重試)
事件對應規則(固定):
hard_bounced:立即設為黑名單(suppressed)soft_bounced:累計達門檻後設為黑名單(suppressed)complaint:設為黑名單(suppressed)並回寫 Member Center(reason=complaint)suppression:設為黑名單(suppressed)
SES Bounce 對應:
bounce.bounceType=Permanent→hard_bouncedbounce.bounceType=Transient→soft_bounced
事件優先序(規劃):
complaint>hard_bounced/suppression>soft_bounced>delivery>open/click- 目前實作尚未建立完整 recipient 狀態機覆蓋所有事件;實際會以
suppressed作為最終不可發狀態
回寫 Member Center 條件:
hard_bounced:設黑名單後回寫soft_bounced:達門檻設黑名單後回寫complaint:設黑名單後立即回寫suppression:設黑名單後回寫
回寫原因碼(固定):
hard_bouncesoft_bounce_thresholdcomplaintsuppression
外部 API:Send Engine → Member Center
以下為 Member Center 端提供的 API,非 Send Engine 的 OpenAPI 規格範圍。
G. 停用訂閱回寫
用途:因 hard bounce / complaint 停用訂閱,並在 Member Center 註記來源。
Endpoint(Member Center 提供):
POST /subscriptions/disable
Scope:
newsletter:events.write
Request Body(示意):
{
"tenant_id": "uuid",
"subscriber_id": "uuid",
"list_id": "uuid",
"reason": "hard_bounce",
"disabled_by": "send_engine",
"occurred_at": "2026-02-10T09:45:00Z"
}
狀態碼與錯誤
通用錯誤:
401/403:Auth 或 scope 不符409:重放或事件重複(nonce / event_id)422:資料格式錯誤500:伺服器內部錯誤
Retry 策略(整合規格)
- Throttle:指數退避重試
- Temporary network error:重試
- Hard failure:不重試
- Retry 上限可設定(例如 5 次)
相關環境參數
Bounce__SoftBounceThreshold:soft bounce 轉黑名單門檻(預設5)