DocuSeal Template Webhook:template.created / updated / archived 事件负载与签名机制详解
【免费下载链接】docusealOpen source DocuSign alternative. Create, fill, and sign digital documents ✍️项目地址: https://gitcode.com/GitHub_Trending/do/docuseal
本文围绕 DocuSeal(开源电子签署平台)的 Template Webhook 展开:完整解析template.created、template.updated、template.archived三个事件的触发时机、Webhook 负载(Payload)的每一个字段,并结合仓库源码深入讲解事件的投递链路、指数退避重试机制与 HMAC 签名校验方式。读完后你将能够搭建一个可校验签名、可处理重试的去重接收端,并把模板变更事件接入到自己的业务系统。
三个模板事件概览
DocuSeal 通过 Webhook 把模板(Template)的生命周期变化推送到你配置的接收地址。针对模板的 Webhook 事件共三个(事件清单定义在 WebhookUrl 模型 的EVENTS常量中):
| 事件类型 | 触发时机 |
|---|---|
template.created | 模板被创建时 |
template.updated | 模板被更新时(包括从归档中恢复) |
template.archived | 模板被归档(软删除)时 |
事件类型到后台 Job 的映射关系维护在 lib/webhook_urls.rb 的EVENT_TYPE_TO_JOB_CLASS中:
'template.created' => SendTemplateCreatedWebhookRequestJob, 'template.updated' => SendTemplateUpdatedWebhookRequestJob, 'template.archived' => SendTemplateArchivedWebhookRequestJobWebhook 负载(Payload)结构
每个事件到达接收端时,都是一个 JSON 请求体,由三部分构成:
event_type(string):事件类型,取值为template.created/template.updated/template.archived;timestamp(string,date-time 格式):事件时间戳,示例"2023-09-24T11:20:42Z";data(object):事件携带的数据对象,即模板详情。
这一顶层结构在发送端 lib/send_webhook_request.rb 中被组装:
req.body = { event_type: event_type, timestamp: webhook_event&.created_at || Time.current, data: data }.to_jsondata 字段完整说明
官方文档给出的data对象结构如下(完整 JSON Schema,见 template-webhook.md):
{ "event_type": { "type": "string", "description": "The event type.", "enum": ["template.created", "template.updated", "template.archived"] }, "timestamp": { "type": "string", "description": "The event timestamp.", "examples": ["2023-09-24T11:20:42Z"], "format": "date-time" }, "data": { "type": "object", "description": "Submitted data object.", "properties": { "id": { "type": "number", "description": "The template's unique identifier." }, "slug": { "type": "string", "description": "The template's unique slug." }, "name": { "type": "string", "description": "The template's name." }, "schema": { "type": "array", "description": "The template document files.", "items": { "type": "object", "properties": { "attachment_uuid": { "type": "string", "description": "The attachment UUID." }, "name": { "type": "string", "description": "The attachment name." } } } }, "fields": { "type": "array", "description": "The template fields.", "items": { "type": "object", "properties": { "uuid": { "type": "string", "description": "The field UUID." }, "submitter_uuid": { "type": "string", "description": "The submitter role UUID." }, "name": { "type": "string", "description": "The field name." }, "type": { "type": "string", "description": "The field type.", "enum": [ "heading", "text", "signature", "initials", "date", "number", "image", "checkbox", "multiple", "file", "radio", "select", "cells", "stamp", "payment", "phone", "verification", "kba", "strikethrough" ] }, "required": { "type": "boolean", "description": "The flag indicating whether the field is required." }, "preferences": { "type": "object", "description": "The field preferences." }, "areas": { "type": "array", "description": "List of areas where the field is located in the document.", "items": { "type": "object", "properties": { "x": { "type": "number", "description": "X coordinate of the area." }, "y": { "type": "number", "description": "Y coordinate of the area." }, "w": { "type": "number", "description": "Width of the area." }, "h": { "type": "number", "description": "Height of the area." }, "attachment_uuid": { "type": "string", "description": "Unique identifier of the attached document where the field is located." }, "page": { "type": "integer", "description": "Page number of the attached document where the field is located." } } } } } } }, "submitters": { "type": "array", "description": "List of submitter roles defined in the template.", "items": { "type": "object", "properties": { "name": { "type": "string", "description": "Submitter name." }, "uuid": { "type": "string", "description": "Unique identifier of the submitter." } } } }, "author_id": { "type": "integer", "description": "Unique identifier of the author of the template." }, "archived_at": { "type": ["string", "null"], "description": "Date and time when the template was archived." }, "created_at": { "type": "string", "description": "Date and time when the template was created." }, "updated_at": { "type": "string", "description": "Date and time when the template was updated." }, "source": { "type": "string", "description": "Source of the template.", "enum": ["native", "api", "embed"] }, "external_id": { "type": ["string", "null"], "description": "Identifier of the template in the external system." }, "folder_id": { "type": "integer", "description": "Unique identifier of the folder where the template is placed." }, "folder_name": { "type": "string", "description": "Folder name where the template is placed." }, "preferences": { "type": "object", "description": "Template preferences object." }, "shared_link": { "type": "boolean", "description": "Flag indicating whether the shared link is enabled for the template." }, "author": { "type": "object", "description": "Author of the template.", "properties": { "id": { "type": "integer", "description": "Unique identifier of the author." }, "first_name": { "type": "string", "description": "First name of the author." }, "last_name": { "type": "string", "description": "Last name of the author." }, "email": { "type": "string", "description": "Author email." } } }, "documents": { "type": "array", "description": "List of documents attached to the template.", "items": { "type": "object", "properties": { "id": { "type": "integer", "description": "Unique identifier of the document." }, "uuid": { "type": "string", "description": "Unique identifier of the document." }, "url": { "type": "string", "description": "URL of the document." }, "preview_image_url": { "type": "string", "description": "Document preview image URL." }, "filename": { "type": "string", "description": "Document filename." } } } } } } }结合 Template 模型 的 schema 注释,可以对几个关键字段补充说明:
slug:模板的唯一短标识,默认由SecureRandom.base58(14)生成(见模型中attribute :slug, :string, default:),用于拼出表单公开地址;source:模板来源,native(站内创建)、api(通过 API 创建)、embed(嵌入式创建);external_id:外部系统中的模板标识,用于对接方与 DocuSeal 之间的 ID 映射;fields[].type:19 种字段类型枚举,覆盖签名(signature)、手写签名(initials)、日期、数字、复选、多选、文件、印章(stamp)、支付(payment)、KBA 核验(kba)等;fields[].areas:字段在 PDF 中的物理位置(x/y/w/h + 所属文档attachment_uuid+ 页码),可用于接收端在文档上高亮或还原字段布局;documents:模板附件文档数组,每个文档带有可直接下载的url与首页预览图preview_image_url。
三个事件的 data 差异:归档事件负载更精简
这一点文档本身没有强调,但从源码可以确认,不同事件的data生成方式不同:
template.created与template.updated:调用Templates::SerializeForApi.call(template)序列化(见 lib/templates/serialize_for_api.rb)。除了上文 schema 中的字段外,实际负载还会包含variables_schema(动态文档变量定义)、application_key(即external_id的别名方法)等键;documents数组中的url/preview_image_url是带签名的 ActiveStorage 代理地址,存在有效期(取账户配置的链接过期时间),接收端应尽快下载或换用 API 拉取,不要长期缓存该 URL。template.archived:在 send_template_archived_webhook_request_job.rb 中仅发送template.as_json(only: %i[id archived_at]),即data只有id和archived_at两个字段。因为模板归档后文档 URL 不再有意义,接收端应基于id去自己的系统中标记状态。
事件在哪些代码路径被触发
从源码结构看,三类事件的入队点分布在 Web 端、API 端和 MCP 端:
template.created(在控制器中通过WebhookUrls.enqueue_events(template, 'template.created')触发):
- Web UI 创建模板:templates_controller.rb 第 48 行;
- 上传文档建模板:templates_uploads_controller.rb 第 28 行;
- 克隆模板(Web 与 API):templates_clone_controller.rb、api/templates_clone_controller.rb;
- 通过 MCP 创建模板:mcp/create_template_controller.rb 第 78 行。
template.updated:
- Web UI 保存模板:templates_controller.rb 第 65 行;
- 从归档中恢复模板:templates_restore_controller.rb 第 11 行;
- API 更新模板:api/templates_controller.rb 第 61 行。
template.archived:
- Web UI 归档/删除:templates_controller.rb 第 79 行(归档是一次
archived_at赋值,删除是物理移除); - API 端:api/templates_controller.rb 中,更新时若
archived置为true(第 63-64 行),以及模板销毁(第 76 行)都会入队。
入队统一经由 lib/webhook_urls.rb 的WebhookUrls.enqueue_events:它先按账户查询订阅了该事件的 Webhook 地址(for_account_id用 SQL 匹配eventsJSON 列),为每个「记录 × Webhook 地址」组合生成一个event_uuid,最后用Sidekiq::Client.push_bulk批量推送到:webhooks队列。
投递机制:签名、重试与审计
HTTP 请求头与传输约束
实际的 POST 请求由 lib/send_webhook_request.rb 发出,接收端可以观察到的固定特征:
Content-Type: application/json;User-Agent: DocuSeal.com Webhook;- 若 Webhook 配置了自定义
secret(任意键值对头),会合并进请求头; X-Docuseal-Signature:HMAC 签名头(见下节);- 超时设置:
open_timeout8 秒、read_timeout15 秒,接收端应在此时间内返回; - 多租户模式下仅允许 HTTPS 端点(443 端口),且禁止向 localhost 发送(除非账户显式开启
allow_http配置)。
X-Docuseal-Signature 签名机制
签名算法实现在 lib/webhook_urls/signatures.rb:
- 每个 Webhook 地址在创建时自动生成一个以
whsec_为前缀、24 字节随机数的密钥(hmac_secret,数据库中以加密列存储,见 WebhookUrl 模型 的encrypts :url, :secret, :hmac_secret); - 签名格式为
"<timestamp>.<hmac>",其中hmac = HMAC-SHA256(secret, "<timestamp>.<原始请求体>"); - 校验时允许时间戳偏差 5 分钟(
TOLERANCE = 5 * 60),用于防重放。
按该库verify的逻辑,接收端等价的校验实现(Ruby 示例)为:
TOLERANCE = 5 * 60 def verify_hmac(secret, body, header) ts, sig = header.to_s.split('.', 2) ts = Integer(ts, exception: false) return false unless ts && sig now = Time.now.to_i return false if ts < now - TOLERANCE || ts > now + TOLERANCE # 时间窗口校验,防重放 expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}") ActiveSupport::SecurityUtils.secure_compare(expected, sig) # 恒定时间比较,防时序攻击 end重试:指数退避与上限
三个 Job(如 send_template_updated_webhook_request_job.rb)结构一致:
- 校验 Webhook 地址存在、URL 非空、且其
events列表仍订阅当前事件(用户在设置里取消订阅后,排队的投递会被静默丢弃); - 调用
SendWebhookRequest.call发送;若响应状态码< 400视为成功,停止; - 失败(含超时、连接失败,
SendWebhookRequest内部 rescue 后返回nil)则按2**attempt分钟延迟重新入队,attempt递增; MAX_ATTEMPTS = 10,超过后放弃。即重试间隔依次为 1、2、4、8、16、32、64、128、256、512 分钟,整个重试窗口从源码结构看可持续数天。
去重与审计日志
每次发送都会写入webhook_events表(以event_uuid + webhook_url_id唯一),并按尝试逐次记录webhook_attempts(状态码、截断的响应体)。关键的去重逻辑在 lib/send_webhook_request.rb 中:若同一event_uuid对应的事件已标记为success,则自动重试范围内(Sidekiq 重复投递)会直接短路,不会重复发送。这保证了接收端在正常网络抖动下收到的语义幂等性;但如需绝对幂等,仍建议接收端以event_uuid(可从日志事件表对账)与业务字段做二次去重。
测试用例 spec/jobs/send_template_updated_webhook_request_job_spec.rb、spec/jobs/send_template_created_webhook_request_job_spec.rb、spec/jobs/send_template_archived_webhook_request_job_spec.rb 覆盖了事件匹配(订阅列表变更后不再投递)与负载字段断言,可作为接收端契约的参考。
接入建议小结
- 订阅时在 Webhook 配置中勾选对应的事件名(
template.created等),注意 Webhook 默认只订阅form.*四类事件,模板事件需显式勾选(见 WebhookUrl 模型 中events的默认值); - 接收端处理流程建议:校验
X-Docuseal-Signature→ 按 5 分钟时间窗拒绝过期请求 → 按event_type分发 → 对template.created/template.updated立即落库并(如需要)抓取documents中带签名 URL 的附件 → 对template.archived仅按id+archived_at更新状态; - 对同一模板的高频编辑,
template.updated可能密集到达,接收端可做防抖或按updated_at单调性去重; - 事件投递失败会指数退避重试,接收端应保持幂等并关注
User-Agent: DocuSeal.com Webhook的请求特征以便过滤。
相关文档
- 表单级事件(viewed / started / completed / declined):form-webhook.md
- 提交级事件(created / completed / expired / archived):submission-webhook.md
- OpenAPI 规范(含 Webhook 端点定义):openapi.json
【免费下载链接】docusealOpen source DocuSign alternative. Create, fill, and sign digital documents ✍️项目地址: https://gitcode.com/GitHub_Trending/do/docuseal
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考