LitoForm
整合功能

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-Typeapplication/json標準
X-Litoform-Signaturesha256=a1b2c3...HMAC-SHA256 簽名(hex)
X-Litoform-Timestamp1747363338Unix 秒級時間戳
X-Litoform-Survey-Idcmmb...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 為去重鍵。

建議接收端策略

  1. 立即回 200 OK 告知 LitoForm「已收到」,再去做後續處理(如寫 DB、轉發到他系統)。
  2. 處理超過 5 秒的工作丟給 background queue 處理,避免 timeout。
  3. 維護 response_id 已處理清單(最近 24 小時即可),重複收到時直接回 200。

6. Troubleshooting

症狀處理
接收端完全沒收到請求確認問卷的 Webhook URL 已設定,且以 https:// 開頭
簽名驗證始終失敗接收端必須使用「原始 request body bytes」計算 HMAC,不要先 parse JSON 再重新 JSON.stringify
接收端 timeout請立即回 200 後再處理,把長工作丟到 background queue
收到重複的 response_idWebhook 重試所致,請以 response_id 為冪等鍵,已處理過的直接回 200
偶爾失敗但接著就好系統會自動重試(1min / 5min / 15min 三次),通常無需處理

On this page