PAYATHON 2026

DocuSign API 在 Production 创建 Envelope 报错

数据小许

结论

当前请求有两个问题需要修正:

  1. documentBase64 中保存的不是 PDF 文件的 Base64,而是模板元数据 JSON 的 Base64。请求却将 fileExtension 声明为 "pdf"。DocuSign 尝试按 PDF 解析这段内容时会失败,并返回:

    {
      "errorCode": "UNSPECIFIED_ERROR",
      "message": "Input string was not in a correct format."
    }
  2. Production 请求地址不应手动固定为 https://na2.docusign.net:8821/...。应从认证或 UserInfo 响应中获取目标账户对应的 base_uri,再拼接 REST API 路径。8821 很可能是 DocuSign 服务端日志中的内部端口,不能作为客户端配置依据。

问题与 embedded signing、Ruby HTTP 库或 tabs 数量无关。精简 payload 后仍然失败,是因为其中的文档内容依然无效。

为什么可以确定 documentBase64 不是 PDF

示例中的 Base64 以以下内容开头:

ew0KICAidGVtcGxhdGVJZCI6...

解码后得到的是:

{
  "templateId": "1b6ccad1-5d1f-403a-b4a0-3e5df5598ae5",
  "templateDocuments": [
    {
      "documentId": "44202387",
      "name": "test.pdf",
      "type": "content",
      "uri": "/templates/1b6ccad1-5d1f-403a-b4a0-3e5df5598ae5/documents/44202387",
      "order": "1",
      "pages": "1"
    }
  ]
}

这段 JSON 只是对模板所含文档的描述,并不是 test.pdf 的二进制内容。

真正的 PDF 经 Base64 解码后,通常以 PDF 文件签名开头:

%PDF-

对应的 Base64 一般以类似下面的内容开头:

JVBERi0

Base64 字符串格式合法,不代表它就是有效的 PDF。Base64 只是一种编码方式,JSON、PDF、图片和普通文本都可以编码成 Base64。

正确获取并编码文档

如果文档来自本地文件,应读取文件的原始二进制内容,再进行编码:

require "base64"

pdf_bytes = File.binread("test.pdf")
document_base64 = Base64.strict_encode64(pdf_bytes)

不要对以下内容进行 Base64 编码:

  • 模板详情响应
  • 模板文档列表响应
  • 包含 templateId、uri 等字段的 JSON
  • 已经编码为 Base64 的字符串
  • HTTP response 对象的字符串表示

如果文件来自 DocuSign 模板,需要调用实际的模板文档下载接口,取得响应 body 中的二进制文件。不要使用模板文档列表接口返回的 JSON。请求形式如下:

GET {base_uri}/restapi/v2.1/accounts/{accountId}/templates/{templateId}/documents/{documentId}
Authorization: Bearer {access_token}
Accept: application/pdf

然后直接编码返回的二进制 body:

pdf_bytes = response.body
document_base64 = Base64.strict_encode64(pdf_bytes)

编码前可以检查文件签名:

unless pdf_bytes.start_with?("%PDF-")
  raise "Downloaded content is not a PDF"
end

发送前也可以解码一次,确认 documentBase64 中确实保存了 PDF:

decoded = Base64.strict_decode64(document_base64)

unless decoded.start_with?("%PDF-")
  raise "documentBase64 does not contain PDF bytes"
end

使用正确的 Production endpoint

不要将 Sandbox 地址替换成自行猜测的 Production 集群地址,也不要固定使用 na2。Production 账户可能位于不同站点。

应读取 OAuth UserInfo 返回的账户信息,并使用目标账户对应的 base_uri:

{
  "accounts": [
    {
      "account_id": "15308603",
      "base_uri": "https://na2.docusign.net"
    }
  ]
}

据此构造请求地址:

https://na2.docusign.net/restapi/v2.1/accounts/15308603/envelopes

不要主动添加 :8821:

https://na2.docusign.net:8821/restapi/...

如果 :8821 只出现在 DocuSign 提供的内部服务器日志中,并不能据此判断客户端实际配置了这个端口。应检查客户端最终发送的 URL,并以认证响应中的 base_uri 为准。

最小可用请求示例

先去掉 anchors、tabs、carbon copies 和 embedded signing,使用经过验证的 PDF 进行最小化测试:

require "base64"
require "json"
require "net/http"
require "uri"

account_id = "15308603"
base_uri = "https://na2.docusign.net"
access_token = ENV.fetch("DOCUSIGN_ACCESS_TOKEN")

pdf_bytes = File.binread("test.pdf")
raise "Invalid PDF" unless pdf_bytes.start_with?("%PDF-")

payload = {
  emailSubject: "DocuSign REST API test",
  documents: [
    {
      documentBase64: Base64.strict_encode64(pdf_bytes),
      name: "test.pdf",
      fileExtension: "pdf",
      documentId: "1"
    }
  ],
  recipients: {
    signers: [
      {
        email: "signer@example.com",
        name: "Test Signer",
        recipientId: "1",
        routingOrder: "1"
      }
    ]
  },
  status: "sent"
}

uri = URI(
  "#{base_uri}/restapi/v2.1/accounts/#{account_id}/envelopes"
)

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{access_token}"
request["Accept"] = "application/json"
request["Content-Type"] = "application/json"
request.body = JSON.generate(payload)

response = Net::HTTP.start(
  uri.hostname,
  uri.port,
  use_ssl: true
) do |http|
  http.request(request)
end

puts response.code
puts response.body

documentId 可以先使用简单的 "1"。它是当前 envelope 内用于关联文档和 tabs 的标识,不必沿用模板中较大的文档 ID。添加 tabs 后,每个 tab 的 documentId 必须与 envelope 中的文档 ID 一致:

{
  "documents": [
    {
      "documentId": "1"
    }
  ],
  "tabs": {
    "signHereTabs": [
      {
        "documentId": "1",
        "recipientId": "1",
        "anchorString": "signer1sig"
      }
    ]
  }
}

如果目的是从模板创建 Envelope

如果 Production 中已有可用模板,就不必先下载模板文档、重新编码,再将其作为新文档上传。可以直接通过 templateId 创建 envelope:

{
  "templateId": "PRODUCTION_TEMPLATE_ID",
  "templateRoles": [
    {
      "roleName": "Customer",
      "name": "Mic Nuss",
      "email": "myemail@gmail.com",
      "clientUserId": "an-alpha-numeric-ID"
    }
  ],
  "status": "sent"
}

需要注意:

  • Sandbox 和 Production 是两个独立环境,templateId 通常不能混用。
  • Production 请求必须使用在 Production 中实际创建或导入后生成的模板 ID。
  • roleName 必须与 Production 模板中的角色名称完全一致。
  • 直接使用模板时,不要同时提交从模板元数据中提取的伪 documentBase64。

Production 侧还应检查的项目

修正文档内容和 endpoint 后,如果请求仍然失败,可以依次检查:

  1. OAuth UserInfo 中的 account_id 是否与 URL 中的 {accountId} 完全一致。
  2. 使用的是否为该账户对应的 base_uri,而不是默认账户或 Sandbox 的地址。
  3. Integrator Key 是否已完成 Production go-live,以及当前认证方式是否被 Production 接受。
  4. Production 账户是否具备发送 envelope 的权限,当前用户是否可以创建和发送 envelope。
  5. 请求体是否由 JSON 序列化器生成,且没有手工拼接或二次编码。
  6. Content-Type 是否为 application/json。
  7. PDF 是否为原始二进制文件的 Base64,而不是 JSON、下载页面或错误响应的 Base64。
  8. 使用模板时,是否采用了 Production 的 templateId 和对应角色。

最直接的修复方法是使用一个本地可以正常打开的 PDF,确认其二进制内容以 %PDF- 开头,通过 Base64.strict_encode64 编码,并以 documentId: "1" 发送到 UserInfo 返回的 Production base_uri。最小请求成功后,再逐步恢复模板、tabs、carbon copies 和 embedded signing 配置。

备注:内容仅供参考。