Webhook 通知設定
每次有人完成填答後,自動將回應資料 POST 到您指定的 URL;支援 HMAC-SHA256 簽名驗證與自動重試。
LitoForm 在每一份問卷被填答完成後,可以自動將回應內容 POST 到您指定的 URL,並附上 HMAC-SHA256 簽名供驗證。本指南說明如何啟用、接收與驗證 Webhook 通知。
Webhook 採非同步機制:失敗或延遲不會影響填答者的提交流程。
適用情境
- 將問卷回應即時同步到您的 CRM、ERP 或自家後端
- 觸發 Email / Slack / Line 通知
- 串接 Zapier、Make.com 等自動化工具
- 寫入資料倉儲做即時分析
1. 啟用 Webhook
進入問卷編輯頁 → 點右上角「設定」 → 切換到「進階設定」分頁,填入下列欄位:
| 欄位 | 必填 | 說明 |
|---|---|---|
| Webhook URL | ✅ | 接收端網址,必須以 https:// 開頭 |
| Webhook Secret | ✅ | 用於 HMAC 簽名的密鑰;按「生成」按鈕產生後請按「複製」並設定到您的接收端 |
點「套用」儲存後,該問卷之後每一筆填答都會觸發 Webhook。
僅接受 https://。http:// 開頭的 URL 在儲存時會被拒絕。因為 payload 內含填答的明文內容,強制 HTTPS 確保傳輸層加密。
2. Payload 格式
LitoForm 以 POST 方式發送,Content-Type: application/json,body 為下列 JSON:
{
"event": "response.submitted",
"survey_id": "cmmbtyptq0001rugkm1d3zwke",
"response_id": "cmmwxyz12abc34def56ghi78",
"submitted_at": "2026-05-16T03:42:18.347Z",
"answers": {
"q1": "張三",
"q2": "[email protected]",
"q3": ["選項A", "選項C"],
"q4": 5
}
}| 欄位 | 說明 |
|---|---|
event | 固定為 "response.submitted" |
survey_id | 問卷 ID |
response_id | 此筆回應的唯一 ID |
submitted_at | 填答送出時間(ISO 8601 UTC) |
answers | 物件,鍵為題目 ID,值為填答內容 |
3. Request Headers
每次 POST 一律帶以下 Header:
| Header | 範例值 | 用途 |
|---|---|---|
Content-Type | application/json | 標準 |
X-Litoform-Signature | sha256=a1b2c3... | HMAC-SHA256 簽名(hex) |
X-Litoform-Timestamp | 1747363338 | Unix 秒級時間戳 |
X-Litoform-Survey-Id | cmmb...zwke | 來源問卷 ID |
4. 驗證簽名
接收端必須驗證 X-Litoform-Signature,確認請求確實來自 LitoForm 且 payload 未被竄改。
簽名公式
signature = "sha256=" + HMAC_SHA256(timestamp + "." + rawBody, secret)關鍵原則:對「原始 request body bytes」簽名,不要重新 JSON.stringify。不同語言或執行環境序列化 JSON 時 key 順序可能不同,重新序列化會導致 HMAC 不匹配(此設計與 GitHub、Stripe 一致)。
Node.js / Express 範例
const crypto = require('crypto')
const express = require('express')
const app = express()
const SECRET = process.env.LITOFORM_WEBHOOK_SECRET
// 關鍵:用 express.raw() 取得原始 bytes,不是 express.json()
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.headers['x-litoform-timestamp']
const signature = req.headers['x-litoform-signature']
if (!timestamp || !signature) {
return res.status(401).json({ error: 'missing signature headers' })
}
// 可選:拒絕超過 5 分鐘前的請求,防重放
const now = Math.floor(Date.now() / 1000)
if (Math.abs(now - Number(timestamp)) > 300) {
return res.status(401).json({ error: 'stale timestamp' })
}
// req.body 是 Buffer(raw bytes)
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), req.body]))
.digest('hex')
// 用 timingSafeEqual 避免 timing attack
const valid =
expected.length === signature.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
if (!valid) {
return res.status(401).json({ error: 'invalid signature' })
}
const payload = JSON.parse(req.body)
console.log('Got response:', payload.response_id)
// 立即回 2xx,再去處理
res.status(200).json({ ok: true })
})
app.listen(8080)PHP 範例
<?php
$secret = getenv('LITOFORM_WEBHOOK_SECRET');
$timestamp = $_SERVER['HTTP_X_LITOFORM_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_LITOFORM_SIGNATURE'] ?? '';
if (!$timestamp || !$signature) {
http_response_code(401);
exit('missing signature headers');
}
// 取原始 request body bytes
$rawBody = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// hash_equals 等同 timing-safe 比對
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('invalid signature');
}
$payload = json_decode($rawBody, true);
error_log('Got response: ' . $payload['response_id']);
http_response_code(200);
echo json_encode(['ok' => true]);Python (Flask) 範例
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ['LITOFORM_WEBHOOK_SECRET'].encode()
@app.route('/webhook', methods=['POST'])
def webhook():
timestamp = request.headers.get('X-Litoform-Timestamp', '')
signature = request.headers.get('X-Litoform-Signature', '')
if not timestamp or not signature:
return jsonify(error='missing signature headers'), 401
# request.data 是原始 bytes
message = f'{timestamp}.'.encode() + request.data
expected = 'sha256=' + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return jsonify(error='invalid signature'), 401
payload = request.get_json()
print('Got response:', payload['response_id'])
return jsonify(ok=True)Go 範例
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var secret = []byte(os.Getenv("LITOFORM_WEBHOOK_SECRET"))
func webhook(w http.ResponseWriter, r *http.Request) {
timestamp := r.Header.Get("X-Litoform-Timestamp")
signature := r.Header.Get("X-Litoform-Signature")
rawBody, _ := io.ReadAll(r.Body)
h := hmac.New(sha256.New, secret)
h.Write([]byte(timestamp + "."))
h.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(h.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
http.Error(w, "invalid signature", 401)
return
}
w.WriteHeader(200)
w.Write([]byte(`{"ok":true}`))
}5. 重試與冪等
當接收端未回 2xx(含 4xx、5xx、timeout、TLS 錯誤等),LitoForm 會自動重試:第 1 次失敗後 1 分鐘、第 2 次後 5 分鐘、第 3 次後 15 分鐘。
請在接收端做冪等處理:相同 response_id 可能因為重試被送多次,請以 response_id 為去重鍵。
建議接收端策略
- 立即回
200 OK告知 LitoForm「已收到」,再去做後續處理(如寫 DB、轉發到他系統)。 - 處理超過 5 秒的工作丟給 background queue 處理,避免 timeout。
- 維護
response_id已處理清單(最近 24 小時即可),重複收到時直接回 200。
6. Troubleshooting
| 症狀 | 處理 |
|---|---|
| 接收端完全沒收到請求 | 確認問卷的 Webhook URL 已設定,且以 https:// 開頭 |
| 簽名驗證始終失敗 | 接收端必須使用「原始 request body bytes」計算 HMAC,不要先 parse JSON 再重新 JSON.stringify |
| 接收端 timeout | 請立即回 200 後再處理,把長工作丟到 background queue |
收到重複的 response_id | Webhook 重試所致,請以 response_id 為冪等鍵,已處理過的直接回 200 |
| 偶爾失敗但接著就好 | 系統會自動重試(1min / 5min / 15min 三次),通常無需處理 |