子網域開通 API
子網域開通 API(Partner Onboarding API)讓平台型商家(母網域)用 API 為旗下的商家申請子網域:送出商家資料與文件、由應援審核,開通後取得子網域專用的 Payment API token,讓子網域用 Payment API 收款。子網域的網址是 {母網域}-{子網域代稱}.oen.tw。
- 建立申請:
POST …/applications帶子網域代稱、組織資料與要上傳的文件清單,回應會給每個文件一個上傳網址。 - 上傳文件:用回應裡的
presignedPutUrl以PUT上傳檔案,見上傳文件。 - 送審:
POST …/submit後狀態變成kyc_pending,等應援審核。 - 應援審核:通過後建立子網域;如果申請時有帶金流申請資料,接著審核金流申請。被退回時,用
PATCH修正後重新送審。 - 取得 token:狀態變成
activated後,用GET …/token取得子網域的 Payment API token。只能取一次,請立即妥善保存。
| 環境 | Base URL |
|---|---|
| 正式環境 | https://api.oen.tw/partner-onboarding/v1 |
| 測試環境 | https://api.testing.oen.tw/partner-onboarding/v1 |
測試環境的開通與 token 同樣由應援設定,子網域網址的結尾也可能不同,請以開通時應援提供的設定與 API 回應為準。
所有路徑都以 /domain/{domain} 開頭,{domain} 是母網域的名稱。例如母網域的應援頁是 https://ming.oen.tw,就填 ming。
每個請求都要帶:
Authorization: Bearer <access token>- access token 由應援的 OAuth 服務(正式環境
https://openapi.oen.tw)核發。開通時,應援會為你建立 OAuth client,並提供取得與更新 token 的方式。 - 完成授權的 CRM 帳號必須是母網域的管理員。同一個帳號管理多個已開通的母網域時,可以依權限呼叫各個母網域。
- API 依序檢查:先驗證 token,再確認母網域已開通,最後檢查權限。
- token 無效或過期:401
X022。 - 母網域不存在或還沒開通:403
PO001。 - 母網域已開通,但帳號沒有它的管理員權限:401
X022(不是 403)。
- token 無效或過期:401
- 應援停用 OAuth client 後,帶有這個 client 識別資訊的 token 會被拒絕(401
X022)。較早核發、沒有這項資訊的 token 可能在到期前仍然有效;需要撤銷憑證時,請聯絡應援確認所有既有 token 的處理方式。 - 查詢不屬於這個母網域的申請編號會回 404
X024。 - CRM 產生的 Payment API token 不能用在這支 API。
- 各端點以 JSON(UTF-8)交換資料,時間是 ISO 8601(UTC)。
- 成功的回應包在
data裡:{ "data": { ... } }。回應可能增加新欄位,請忽略不認得的欄位。 - 業務錯誤帶
error與errno,部分錯誤另有detail。正式環境不回message。
{ "error": "child_slug_taken", "errno": "PO003"}少數錯誤沒有 errno,請一定也看 HTTP 狀態碼:
- body 欄位不符合格式時回 400,
error是錯誤說明,detail.fieldValidationErrors會指出是哪個欄位。例如childSlug長度不對、附件的field與documentKind搭配不對、confirmHash不是 64 個字元。 - body 不是合法的 JSON 時回 400
X027。 - 這支 API 的端點使用
GET、POST、PATCH。OPTIONS預檢回 200,body 是純文字success;其他 method 回 405{ "error": "method_not_allowed" };路徑不存在,或那個路徑不支援這個 method,回 404X024。
| 狀態 | 說明 | 你可以做的事 |
|---|---|---|
drafting |
已建立,還沒送審 | 上傳文件、送審、撤回 |
kyc_pending |
已送審,等應援審核商家資料與文件 | 撤回 |
kyc_rejected |
商家資料被退回,原因見 rejectionReasons |
補件修改、撤回 |
kyc_approved |
商家資料審核通過,子網域已建立 | 撤回 |
payment_submitted |
金流申請已送出,等待審核結果 | 撤回 |
payment_rejected |
金流申請被退回,原因見 providerResponse |
補件修改、撤回 |
payment_approved |
金流申請通過的過渡狀態,通常會直接以 activated 出現 |
撤回(已經是 activated 就不能撤回) |
activated |
開通完成 | 取得 token |
withdrawn |
已撤回 | — |
- 狀態的順序:
drafting→kyc_pending→kyc_approved→(有金流申請時)payment_submitted→payment_approved→activated。沒有帶金流申請時,kyc_approved直接變成activated。 activated與withdrawn是終態,不會再變。drafting不能修改資料。補件修改只能在kyc_rejected或payment_rejected時使用。送審前發現資料有誤,請撤回後重新建立申請。- 商家資料審核通過後,子網域就已經建立。之後撤回申請,子網域仍會保留,同一個子網域代稱也不能再用;需要處理時請聯絡應援。
- 審核通過後如果金流申請沒有成功送出,申請會停在
kyc_approved,這時也無法補件修改,請聯絡應援處理。
| Method | 路徑 | 說明 |
|---|---|---|
POST |
/domain/{domain}/applications |
建立申請 |
GET |
/domain/{domain}/applications/{applicationId} |
查詢申請 |
PATCH |
/domain/{domain}/applications/{applicationId} |
補件修改 |
POST |
/domain/{domain}/applications/{applicationId}/submit |
送審 |
POST |
/domain/{domain}/applications/{applicationId}/withdraw |
撤回 |
GET |
/domain/{domain}/applications/{applicationId}/token |
取得子網域 token |
沒有列出所有申請的端點,請自行保存每一筆申請的 applicationId。各端點都可能回 PO001、X022、X030、5xx,以及共通規格提到的格式錯誤,下面不再重複列出。
POST /domain/{domain}/applications
Header 除了 Authorization,還要帶 Content-Type: application/json 與 Idempotency-Key(見 Idempotency-Key)。
| Body 欄位 | 必填 | 說明 |
|---|---|---|
childSlug |
必填 | 子網域代稱。3 到 32 個字元(長度不對回 400,帶 detail.fieldValidationErrors),只能用小寫英文、數字與 -,開頭與結尾不能是 -。部分保留字(例如 admin、api、www、test)不能使用,字元或保留字不符回 400 PO002。子網域網址會是 {domain}-{childSlug}.oen.tw |
orgInfo |
必填 | 子網域的組織資料,見 orgInfo。建立時不檢查內容,送審時才檢查 |
attachments |
必填 | 要上傳的文件清單,0 到 20 項,見上傳文件。沒有文件時帶 [] |
paymentApplication |
選填 | 金流申請資料,必須是 JSON 物件。只要帶了這個欄位(即使是 {}),審核通過後就會送出金流申請,之後也無法移除。不需要金流服務的子網域,請完全不要帶這個欄位。格式請在開通時向應援索取;裡面的附件請不要自行填寫,要上傳的文件一律放在頂層的 attachments |
curl -X POST "https://api.oen.tw/partner-onboarding/v1/domain/ming/applications" \ -H "Authorization: Bearer $OEN_PARTNER_TOKEN" \ -H "Idempotency-Key: 9f1c2a7e-4b1d-4e2a-bc77-2f0e1d3a9c01" \ -H "Content-Type: application/json" \ -d '{ "childSlug": "store1", "orgInfo": { "orgType": "business", "orgName": "範例商行", "taxIdNumber": "12345678", "orgEmail": "owner@example.com", "orgPhone": "0912345678" }, "attachments": [ { "field": "companyRegistrationCert", "filename": "registration.pdf", "contentType": "application/pdf" } ] }'回應 201:
{ "data": { "applicationId": "2f6Yk9Lm3Qp8Rt1Vw4Xz7Bc0Dn5", "status": "drafting", "childSlug": "store1", "fullDomain": "ming-store1.oen.tw", "attachments": [ { "field": "companyRegistrationCert", "filename": "registration.pdf", "contentType": "application/pdf", "key": "pending_activation/2f6Yk9Lm3Qp8Rt1Vw4Xz7Bc0Dn5/companyRegistrationCert/2f6Yk9Q1w2E3r4T5y6U7i8O9p0A.pdf", "presignedPutUrl": "https://…amazonaws.com/…&X-Amz-Signature=…", "expiresAt": "2026-10-15T02:31:00.000Z" } ], "createdAt": "2026-10-15T02:30:00.000Z", "updatedAt": "2026-10-15T02:30:00.000Z" }}可能的錯誤:PO002(子網域代稱字元不對或是保留字)、PO003(子網域網址已被使用或保留)、PO004、PO005、X034。
建立申請如果逾時、沒拿到 applicationId,請用同一把 Idempotency-Key 原樣重送。仍然拿不到,或收到 PO003 卻沒有這筆申請的編號,請把母網域、childSlug、請求時間與 Idempotency-Key 提供給應援查詢,不要換 key 或換代稱重送。
orgInfo
Section titled “orgInfo”| 欄位 | 說明 |
|---|---|
orgType |
組織類型,見下表。送審時必填 |
orgName |
組織名稱 |
displayName |
顯示名稱 |
orgEmail、orgPhone |
用來指定子網域的管理員:系統先用手機找應援帳號,找不到再用 Email 找,都找不到就建立新帳號,並把它設為子網域的管理員。請確認兩者都屬於同一位預定的管理員,不要填不同人的資料,也不要填共用的客服信箱或電話。orgPhone 可以是字串,或 { "phone": "…", "ext": "…" } |
ownerName、contactName |
負責人與聯絡人姓名 |
TWIDNumber |
身分證字號(個人) |
taxIdNumber、registrationNumber、approvalLetterNumber |
統一編號、立案(登記)字號、許可函文號,依組織類型必填,見下表 |
送審時會檢查 orgType 與必填的號碼:
orgType |
類型 | 送審時必填 |
|---|---|---|
business |
公司行號 | taxIdNumber |
personal |
個人 | — |
charity |
公益團體 | registrationNumber、taxIdNumber |
religion |
宗教團體 | registrationNumber、taxIdNumber |
education |
教育機構 | registrationNumber、taxIdNumber |
politics |
政黨 | approvalLetterNumber、taxIdNumber |
candidate |
候選人(擬參選人) | approvalLetterNumber |
orgType 請只用上表 7 種值。缺少必填號碼時,送審回 400 X034,不會說明缺哪一項。
GET /domain/{domain}/applications/{applicationId}
回應 200:
{ "data": { "applicationId": "2f6Yk9Lm3Qp8Rt1Vw4Xz7Bc0Dn5", "status": "kyc_rejected", "childSlug": "store1", "fullDomain": "ming-store1.oen.tw", "history": [ { "status": "drafting", "occurredAt": "2026-10-15T02:30:00.000Z", "actor": "parent", "requestId": "…" }, { "status": "kyc_pending", "occurredAt": "2026-10-15T02:35:00.000Z", "actor": "parent", "requestId": "…", "action": "application.submit", "beforeStatus": "drafting" }, { "status": "kyc_rejected", "occurredAt": "2026-10-16T03:00:00.000Z", "actor": "oen-bd", "requestId": "…", "action": "application.kyc_reject", "beforeStatus": "kyc_pending" } ], "rejectionReasons": [ { "field": "orgInfo.taxIdNumber", "reason": "統一編號與登記文件不符" } ], "attachments": [ { "field": "companyRegistrationCert", "filename": "registration.pdf" } ], "createdAt": "2026-10-15T02:30:00.000Z", "updatedAt": "2026-10-16T03:00:00.000Z" }}| 欄位 | 說明 |
|---|---|
history |
狀態歷程。actor 是 parent(你)、oen-bd(應援)或 system;部分紀錄另有 action 與 beforeStatus |
rejectionReasons |
商家資料被退回的原因,每項有 field 與 reason |
providerResponse |
金流申請被退回時的原因,可能有 reasonCode 與 reasonDescription,也可能是空物件;原因不清楚時請聯絡應援 |
attachments |
已登記的文件(只有 field、filename、documentKind),不含上傳網址 |
confirmHash |
文件清單的雜湊值。只有在某個文件帶了 documentKind 時才會出現,送審時要帶上,見送審 |
回應不包含 orgInfo 與 paymentApplication,請自行保存送出的內容。
可能的錯誤:X024(找不到申請)。
PATCH /domain/{domain}/applications/{applicationId}
只能在 kyc_rejected 或 payment_rejected 時使用,修改後狀態回到 drafting,請再送審。Header 要帶 Idempotency-Key。
| Body 欄位 | 說明 |
|---|---|
orgInfo |
只更新你帶的第一層欄位,沒帶的欄位維持原值。如果某個欄位本身是物件或陣列,會整個取代,請帶完整內容 |
paymentApplication |
同上 |
attachments |
帶了就整份取代原本的文件清單,並給新的上傳網址。清單裡每一個文件都要重新上傳 |
Body 必須是 JSON 物件,不能帶其他欄位。即使是 {} 也會讓申請回到 drafting,並清空 rejectionReasons 與 providerResponse;回到 drafting 後就不能再補件修改,請一次把要修改的內容帶齊。
子網域建立後,補件修改 orgEmail 或 orgPhone 不會撤銷原管理員的權限,重新核准時新的帳號會另外被加為管理員。要更換管理員,或修正先前授權給錯誤帳號的情況,請聯絡應援移除舊的權限。
回應 200,data 有 applicationId、status(drafting)、attachments 與 updatedAt。只有這次有帶 attachments 時,才會給新的上傳網址;沒帶時回的是原本保存的資料,裡面的網址可能早已過期。
可能的錯誤:PO004、PO005、PO006(狀態不是 kyc_rejected 或 payment_rejected)、X024、X034。
POST /domain/{domain}/applications/{applicationId}/submit
Header 要帶 Idempotency-Key。Body 可以不帶,或只帶 confirmHash:
| Body 欄位 | 說明 |
|---|---|
confirmHash |
有任何文件帶了 documentKind 時必填,值是查詢申請回應裡的 confirmHash(64 個字元),用來確認你送審的是目前登記的文件清單與分類;它不檢查檔案內容。請上傳完所有文件、再查詢取得最新的值。其他情況可以不帶,帶了就要相符 |
送審時依序檢查:
- 狀態必須是
drafting,否則回 409PO006。 orgInfo的orgType與必填號碼(見 orgInfo),不符回 400X034。paymentApplication裡的附件都屬於這筆申請,不符回 400X034。confirmHash,缺少或不符回 400X034。- 清單裡的每一個文件都已上傳,有缺回 400
PO007(不會說明缺哪一個,請自行比對)。上傳後請用新的Idempotency-Key再送審。 - 最後保存送審狀態並再次確認子網域代稱,衝突時回 409
PO003或PO006。
回應 202:
{ "data": { "applicationId": "2f6Yk9Lm3Qp8Rt1Vw4Xz7Bc0Dn5", "status": "kyc_pending", "submittedAt": "2026-10-15T02:35:00.000Z", "updatedAt": "2026-10-15T02:35:00.000Z" }}可能的錯誤:PO003、PO004、PO005、PO006、PO007、X024、X034。
POST /domain/{domain}/applications/{applicationId}/withdraw
Header 要帶 Idempotency-Key。Body 不帶或帶 {}。activated 與 withdrawn 以外的狀態都可以撤回。撤回後子網域代稱會釋出,可以再建立新的申請;但商家資料審核通過後子網域已經建立,撤回也不會刪除(見申請狀態)。
回應 200,data 有 applicationId、status(withdrawn)、withdrawnAt 與 updatedAt。
可能的錯誤:PO004、PO005、PO006、X024、X034。
取得子網域 token
Section titled “取得子網域 token”GET /domain/{domain}/applications/{applicationId}/token
狀態是 activated 時,取得子網域的 Payment API token。不需要 Idempotency-Key。
{ "data": { "childApiToken": "eyJ…", "childDomain": "ming-store1.oen.tw", "issuedAt": "2026-10-17T05:00:00.000Z" }}childApiToken就是子網域的 Payment API token:呼叫 Payment API 時當作Bearertoken,merchantId填子網域名稱(例如ming-store1)。- 只能取一次:第二次會回 410
PO008,detail.retrievedAt是第一次取得的時間。第一次的回應如果因為網路問題沒收到,之後也只會拿到PO008,這時請聯絡應援重設。 - token 沒有到期日,請像密碼一樣保管。外洩時請立即聯絡應援重設:重設後舊的 token 立即失效,你可以再呼叫這個端點取得新的 token。在 CRM 重新產生 token 則有 7 天新舊並存期,不等於立即撤銷,也不會讓這個端點可以再取一次。
- 狀態是
activated就可以取 token,不需要等application.token_ready通知。 - 子網域的金流服務開通後,才能用這個 token 收款。沒有帶金流申請的子網域,token 有效但建立交易等收款 API 會被拒絕。
可能的錯誤:PO006(狀態不是 activated)、PO008、X024。
建立申請或補件修改時,attachments 列出要上傳的文件,回應會給每個文件一個上傳網址。
| 欄位 | 必填 | 說明 |
|---|---|---|
field |
必填 | 文件類型,見下表 |
filename |
必填 | 檔名,1 到 255 個字元 |
contentType |
必填 | 檔案格式,見下方的格式限制 |
documentKind |
選填 | 登記文件的細分類型,只能用在 companyRegistrationCert、legalRegistrationCert,見下表 |
field |
文件 |
|---|---|
companyRegistrationCert |
商業完整登記書/立案證書 |
legalRegistrationCert |
法人完整登記證書 |
politicalDonationApproval |
政治獻金核准函 |
taxWithholdingRegistration |
扣繳單位登記申請書(統一編號配置書) |
charityFundraisingApproval |
衛福部公益勸募許可 |
organizationalCharter |
組織章程 |
educationDepartmentLetter |
教育部函 |
directorsList |
董監事名冊 |
shareholdersList |
股東名冊 |
doorplatePhoto |
門牌照 |
productPhoto |
營運項目證明(商品或服務照片) |
additionalDocs |
其他補充文件 |
documentKind 可以是:companyApprovalLetter、companyRegistrationForm、businessApprovalLetter、businessRegistrationExtract、foundationRegistrationCertificate、peopleOrganizationCertificate、associationRegistrationCertificate、institutionRegistrationCertificate。各組織類型需要準備哪些文件,請在開通時向應援確認。
curl -X PUT "<presignedPutUrl>" \ -H "Content-Type: application/pdf" \ --data-binary @registration.pdf- 上傳網址只有 60 秒有效,
expiresAt是到期時間。拿到後請立即上傳。 Content-Type必須和登記的contentType完全相同,否則上傳會失敗。- 網址過期時,在
drafting狀態下用同一把Idempotency-Key原樣重送「產生目前文件清單」的那次請求(建立申請,或最後一次帶了attachments的補件修改),回應會給新的上傳網址。補件換過文件清單後,不要重送更早的請求,那會拿到舊清單的網址。請保存每一次請求的內容、key 與回應。
檔案格式與大小
Section titled “檔案格式與大小”API 接受 image/jpeg、image/png、image/webp、image/heic、image/heif、application/pdf。有金流申請時,應援核准前會檢查檔案的實際內容、格式、大小與重複,只接受下列檔案(沒有金流申請時的文件要求,請在開通時向應援確認):
| 文件 | 格式 | 大小 |
|---|---|---|
| 門牌照、營運項目證明 | JPEG、PNG | 門牌照 5 MB 以內;營運項目證明 10 MB 以內 |
| 其他文件 | JPEG、PNG、PDF | 5 MB 以內 |
1 MB 以 1,048,576 bytes 計。API 不會在上傳或送審時擋下不符合的檔案,上傳成功或送審成功都不代表文件已通過檢查。請不要上傳 WebP、HEIC 或超過大小的檔案。
同一份檔案不要重複上傳;唯一的例外是同一份登記證明可以同時用在 companyRegistrationCert、legalRegistrationCert、taxWithholdingRegistration。
Idempotency-Key
Section titled “Idempotency-Key”建立申請、補件修改、送審、撤回一定要帶 Idempotency-Key。查詢申請與取得 token 不需要。
- 格式:1 到 255 個可見 ASCII 字元,不能有空白。缺少或格式不對回 400
X034。 - key 的比對範圍包含母網域、HTTP method 與請求路徑。同一個範圍、同一把 key、不同的內容,回 409
PO004。 - 同一把 key、同樣的內容重送,會回第一次保存的結果(HTTP 狀態碼與內容),包括第一次回的業務錯誤(例如
PO003、PO006、PO007,以及送審時的X034)。重送的回應是當時的結果;建立申請與補件修改的重送會依目前狀態更新或拿掉上傳網址。目前的狀態請用查詢申請取得。 - 保存的結果不會過期。收到業務錯誤、修正後要重新送出時,請用新的 key。
- 第一次的請求還在處理中,回 409
PO005,稍後用同一把 key 再送。超過約 5 分鐘還沒有結果,下一次重送會重新處理。 - 以下錯誤不會保存,可以用同一把 key 重送:格式驗證錯誤(含
X027、PO002、Idempotency-Key本身的X034)、PO004、PO005、429X030、沒有errno的系統錯誤。 - 逾時或收到 5xx 時:已經有
applicationId的,先用查詢申請確認狀態,再用同一把 key 重送;建立申請還沒拿到applicationId的,見建立申請。
Webhook
Section titled “Webhook”申請狀態改變時,應援會送出 webhook。接收網址與簽章密鑰由應援在開通時幫你設定;設定完成前,請用查詢申請確認狀態。
通知可能延遲數分鐘、重複,少數情況也可能沒有送達,到達順序也不一定等於狀態改變的順序。請把通知當作「去查詢」的提示,以查詢申請的結果為準。
收到通知、驗證簽章後,請以 id 為唯一鍵先把事件存下來,存好才回 2xx;已經存過的重複事件直接回 2xx,存檔失敗回 5xx 讓應援重試。耗時的處理放到背景做。
| 事件 | 什麼時候 | data 另外帶的欄位 |
|---|---|---|
application.created |
建立申請 | childSlug、fullDomain |
application.kyc_pending |
送審 | — |
application.kyc_approved |
商家資料審核通過 | approvedAt |
application.kyc_rejected |
商家資料被退回 | rejectionReasons |
application.payment_submitted |
金流申請已送出 | — |
application.payment_approved |
金流申請通過 | — |
application.payment_rejected |
金流申請被退回 | providerResponse |
application.activated |
開通完成 | fullDomain |
application.token_ready |
token 已準備好,或應援重設 token 之後。不保證每次開通都會發,activated 就可以取 token |
— |
application.withdrawn |
撤回 | — |
補件修改不會發 webhook。重新送審後再次通過,或應援重設 token 時,會再發一次對應的事件。表中只列出供串接使用的欄位,data 裡其他沒有列出的欄位請忽略,不要依賴。
內容與 header
Section titled “內容與 header”POST https://shop.example.com/oen/onboarding-webhookContent-Type: application/jsonOenPay-Event-Type: application.activatedOenPay-Event-Id: evt_7c2d9e1f3a4b5c6d7e8f9a0bOenPay-Signature: t=1792213200,v1=<HMAC-SHA256 簽章>{ "id": "evt_7c2d9e1f3a4b5c6d7e8f9a0b", "type": "application.activated", "api_version": "2026-06-01", "created": 1792213200, "data": { "applicationId": "2f6Yk9Lm3Qp8Rt1Vw4Xz7Bc0Dn5", "status": "activated", "occurredAt": "2026-10-17T05:00:00.000Z", "fullDomain": "ming-store1.oen.tw" }}| 欄位 | 說明 |
|---|---|
id |
事件編號,evt_ 開頭。同一次投遞的重試沿用同一個 id |
type |
事件名稱,同 OenPay-Event-Type |
api_version |
事件格式版本 |
created |
事件建立時間(Unix 秒)。重試時不變;驗證簽章請用 header 裡的 t |
data.applicationId、data.status、data.occurredAt |
哪一筆申請、變成什麼狀態、什麼時候 |
其他 data 欄位 |
見上方事件表。fullDomain 是完整網址,例如 ming-store1.oen.tw |
重試時另外帶 OenPay-Retry-Count header,簽章與 t 會重新產生。
OenPay-Signature 的格式是 t=<Unix 秒>,v1=<hex>,簽章是以簽章密鑰對 `${t}.${原始 body}` 做 HMAC-SHA256。驗證簽章時要用收到的原始 body,並檢查 t 與現在相差不超過 5 分鐘。更換密鑰後 24 小時內會帶兩個 v1=,任一個相符就通過。
# 在本機模擬一個帶簽章的通知,測試你的驗簽程式(測試資料,不是實際的通知)BODY='{"id":"evt_test_0001","type":"application.activated","api_version":"2026-06-01","created":1792213200,"data":{"applicationId":"test_application_id","status":"activated","occurredAt":"2026-10-17T05:00:00.000Z","fullDomain":"ming-store1.oen.tw"}}'T=$(date +%s)SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$OEN_ONBOARDING_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "http://localhost:3000/webhooks/oen" \ -H "Content-Type: application/json" \ -H "OenPay-Event-Type: application.activated" \ -H "OenPay-Event-Id: evt_test_0001" \ -H "OenPay-Signature: t=$T,v1=$SIG" \ -d "$BODY"import crypto from "node:crypto";import express from "express";
const app = express();const TOLERANCE_SECONDS = 300;const SECRET = process.env.OEN_ONBOARDING_WEBHOOK_SECRET;// 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if (!SECRET) throw new Error("OEN_ONBOARDING_WEBHOOK_SECRET 未設定");
// OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 `${t}.${原始 body}`function verifySignature(rawBody, header, secret) { const parts = (header ?? "").split(",").map((p) => p.trim()); const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2)); const sigs = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3)); if (!Number.isFinite(t) || sigs.length === 0) return false; if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest(); // 更換密鑰後 24 小時內會帶兩個 v1=,任一個相符就通過 return sigs.some((sig) => { const received = Buffer.from(sig, "hex"); return received.length === expected.length && crypto.timingSafeEqual(received, expected); });}
// 請接上你的資料庫或可靠的工作佇列:以 event.id(evt_ 開頭) 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出錯誤。完成實作前請維持拋錯,不要回報成功。async function saveEventOnce(event) { throw new Error("saveEventOnce 尚未實作");}
// 驗簽要用原始 body,不要先 JSON.parseapp.post("/webhooks/oen", express.raw({ type: "application/json" }), async (req, res) => { const rawBody = req.body.toString("utf8"); if (!verifySignature(rawBody, req.header("OenPay-Signature"), SECRET)) { return res.status(400).send("invalid signature"); } const event = JSON.parse(rawBody); try { await saveEventOnce(event); // 存好才回 200 } catch { return res.sendStatus(503); // 存檔失敗,讓應援重試 } res.sendStatus(200); // 出貨、寄信等耗時工作由背景程序處理已保存的事件});<?php$payload = file_get_contents('php://input');$header = $_SERVER['HTTP_OENPAY_SIGNATURE'] ?? '';$secret = getenv('OEN_ONBOARDING_WEBHOOK_SECRET');// 沒有密鑰就不處理:用空字串驗簽,任何人都能算出正確的簽章if ($secret === false || $secret === '') { http_response_code(503); exit('webhook secret not configured');}
// OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 "{t}.{原始 body}"$t = null;$sigs = [];foreach (explode(',', $header) as $part) { [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); if ($key === 't') $t = (int) $value; if ($key === 'v1') $sigs[] = $value;}
$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);$matched = false;foreach ($sigs as $sig) { if (hash_equals($expected, $sig)) $matched = true;}
if ($t === null || abs(time() - $t) > 300 || !$matched) { http_response_code(400); exit('invalid signature');}
// 請接上你的資料庫或可靠的工作佇列:以 $event['id'](evt_ 開頭) 為唯一鍵保存事件,// 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。function save_event_once(array $event): void { throw new RuntimeException('save_event_once 尚未實作');}
$event = json_decode($payload, true);try { save_event_once($event); // 存好才回 200} catch (Throwable $error) { http_response_code(503); // 存檔失敗,讓應援重試 exit;}http_response_code(200);import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ.get("OEN_ONBOARDING_WEBHOOK_SECRET", "")# 沒有密鑰就不啟動:用空字串驗簽,任何人都能算出正確的簽章if not SECRET: raise RuntimeError("OEN_ONBOARDING_WEBHOOK_SECRET 未設定")
# OenPay-Signature: t=<unix 秒>,v1=<hex>[,v1=<hex>],簽的是 f"{t}.{原始 body}"def verify_signature(payload: bytes, header: str, secret: str) -> bool: parts = [p.strip() for p in header.split(",")] t = next((p[2:] for p in parts if p.startswith("t=")), "") sigs = [p[3:] for p in parts if p.startswith("v1=")] if not t.isdigit() or not sigs: return False if abs(time.time() - int(t)) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + payload, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in sigs)
# 請接上你的資料庫或可靠的工作佇列:以 event["id"](evt_ 開頭) 為唯一鍵保存事件,# 已保存過也算成功;保存失敗要拋出例外。完成實作前請維持拋出例外,不要回報成功。def save_event_once(event: dict) -> None: raise NotImplementedError("save_event_once 尚未實作")
@app.post("/webhooks/oen")def oen_webhook(): payload = request.get_data() if not verify_signature(payload, request.headers.get("OenPay-Signature", ""), SECRET): abort(400) event = request.get_json() try: save_event_once(event) # 存好才回 200 except Exception: return "", 503 # 存檔失敗,讓應援重試 return "", 200- 回 2xx、並且完整收到回應才算成功。回應 body 請保持精簡(例如回 204 或很短的 200),超過 2048 bytes 也算失敗。
- 不會跟隨轉址(3xx 算失敗);連線或回應超過 30 秒沒有動靜也算失敗。
- 自動投遞每個事件最多 5 次(第一次加上 4 次重試)。每次失敗後,分別約 5 分鐘、30 分鐘、2 小時、24 小時再試一次,實際時間可能稍晚。
- 接收網址必須是 https,不能指向內部網路位址。送出 webhook 的來源 IP 不固定。
| errno | HTTP | error |
意思 | 建議處理 |
|---|---|---|---|---|
PO001 |
403 | parent_not_promoted |
母網域還沒被設定為可以開通子網域 | 聯絡應援 |
PO002 |
400 | child_slug_invalid |
子網域代稱的字元不對或是保留字(長度不對是欄位格式錯誤) | 換一個代稱 |
PO003 |
409 | child_slug_taken |
子網域網址已被使用或保留 | 換一個代稱 |
PO004 |
409 | idempotency_key_reused |
同一把 Idempotency-Key 用在不同的請求內容 |
換一把新的 key |
PO005 |
409 | idempotency_key_in_progress |
同一把 key 的請求還在處理中 | 稍後用同一把 key 再送 |
PO006 |
409 | invalid_state_transition |
目前的狀態不能做這個動作 | 查詢申請目前的狀態,見申請狀態 |
PO007 |
400 | attachments_incomplete |
有文件還沒上傳 | 重新取得上傳網址並上傳後,用新的 key 再送審 |
PO008 |
410 | token_already_retrieved |
token 已經取過,detail.retrievedAt 是取得時間 |
需要新的 token 請聯絡應援重設 |
X022 |
401 | Unauthorized |
token 無效、過期,或沒有這個母網域的權限,見驗證 | 重新取得 token;仍失敗請聯絡應援 |
X024 |
404 | Not Found |
找不到申請,或路徑錯誤 | 確認 applicationId 與路徑 |
X027 |
400 | JSON_BODY_PARSE_FAILED |
body 不是合法的 JSON | 修正 body |
X030 |
429 | rate_limit_exceeded |
超過頻率限制 | 見頻率限制 |
X034 |
400 | Bad Request |
請求內容不符合規則,正式環境不會說明原因 | 依各端點的規則逐項檢查,見共通規格;修正後用新的 key 再送 |
| (無) | 400 | 錯誤說明 | body 欄位不符合格式,detail.fieldValidationErrors 指出欄位 |
修正欄位 |
| (無) | 405 | method_not_allowed |
不支援的 HTTP method(例如 PUT、DELETE) | 確認 method 與路徑 |
| 端點 | 每個母網域的上限 |
|---|---|
| 建立申請 | 每小時 20 次、每天 100 次 |
| 建立申請與補件修改(合計) | 每小時 200 次 |
| 查詢申請 | 每小時 300 次 |
| 送審 | 每小時 30 次 |
| 撤回 | 每小時 30 次 |
| 取得 token | 每小時 30 次 |
- 另外,每個來源 IP 每分鐘最多 60 次,這個額度與撥款指示 API 共用。
- 用同一把
Idempotency-Key重送、直接拿到保存結果的請求,只計入 IP 額度,不計入母網域的額度。 - 超過時回 429
X030,header 帶Retry-After(建議等待的秒數)與X-RateLimit-Limit、X-RateLimit-Remaining、X-RateLimit-Reset(Unix 秒)。被拒絕的請求也會計入次數,請逐步拉長間隔再試。 - 正常的回應通常也會帶
X-RateLimit-*header,數字是當下最接近上限的那一項(可能是 IP,也可能是母網域)。這些 header 偶爾會沒有,程式請不要依賴它們一定存在。
導覽助手暫停服務中。請使用右上角的搜尋,或聯絡客服。
我可以幫你選擇收款方式、回答 API 串接與 CRM 操作問題,並附上文件出處。
