renue

ARTICLE

DocuSign eSignature REST API統合の実装ガイド【2026年版】— JWT Grant認証×デモ/本番自動判定×封筒作成ヘルパーの本番アーキテクチャ

公開日: 2026/4/6

DocuSign eSignature REST APIは世界シェア1位の電子署名サービスのAPIだが、「JWT Grant認証をどうPython本番コードに落とし込むか」「デモ/本番の切り替えをどう設計するか」「封筒(Envelope)作成のペイロードをどう構造化するか」といった実装者が本当に詰まるポイントは公式ドキュメントだけでは把握しきれない。本記事ではrenueが運用しているDocuSignClientの実装をもとに、JWT Grant認証・アカウントIDパス設計・base_url自動判定・封筒作成・書類ダウンロードを含む本番品質の統合パターンを解説する。

DocuSign APIの基礎とJWT Grant認証の位置付け

DocuSign eSignature APIにはAuthorization Code Grant(ユーザーログイン)とJWT Grant(サーバー間連携)の2種類がある。バッチ/自動化/社内システム組込みではJWT Grantを選ぶのが定石だが、以下の前提を揃える必要がある。

  • Integration Key: DocuSign管理画面で発行するアプリ識別子
  • RSA鍵ペア: 公開鍵をDocuSign側に登録、秘密鍵はサーバー保管
  • User ID (GUID): 署名を代行するユーザーのGUID
  • Account ID: APIコール先のDocuSignアカウントID
  • 事前同意(consent): impersonationスコープには対象ユーザーの明示同意が必要

これらを取り違えると「403 consent_required」「400 invalid_grant」等の分かりにくいエラーに遭遇する。特にconsent取得は初回のみブラウザ手動承認が必要な点が見落とされがちだ。

レイヤー1: デモ/本番環境の自動判定

DocuSignは開発用のdemo環境(demo.docusign.net)と本番環境(docusign.net)でOAuthエンドポイントが異なる。切り替えミスが最も多いバグの温床なので、base_urlから自動判定するヘルパーを用意すると事故が減る。

class DocuSignClient:
    OAUTH_BASE_URL_DEMO = "https://account-d.docusign.com"
    OAUTH_BASE_URL_PROD = "https://account.docusign.com"

    def __init__(self, ..., is_demo: Optional[bool] = None):
        self.base_url = base_url or env_setting.DOCUSIGN_BASE_URL

        # is_demoが未指定の場合、base_urlのホストから自動判定
        if is_demo is None:
            self.is_demo = self._infer_is_demo(self.base_url)
        else:
            self.is_demo = is_demo

        self.oauth_base_url = (
            self.OAUTH_BASE_URL_DEMO if self.is_demo else self.OAUTH_BASE_URL_PROD
        )

    @staticmethod
    def _infer_is_demo(base_url: str) -> bool:
        from urllib.parse import urlparse
        parsed = urlparse(base_url or "")
        hostname = parsed.hostname or ""
        return hostname == "demo.docusign.net"

明示指定も許しつつ未指定時は自動判定する2段構え。環境変数設定ミスを検出しやすくなる。

レイヤー2: JWT Grant認証の実装

JWT Grant認証の核心はRS256署名付きJWTを作ってOAuthエンドポイントに投げ、アクセストークンに交換する流れだ。ペイロードのaud(audience)はデモ/本番で変える必要がある。

import jwt, time

def _create_jwt_assertion(self) -> str:
    now = int(time.time())
    payload = {
        "iss": self.integration_key,
        "sub": self.user_id,
        "aud": "account-d.docusign.com" if self.is_demo else "account.docusign.com",
        "iat": now,
        "exp": now + 3600,
        "scope": "signature impersonation",
    }
    return jwt.encode(payload, self.private_key, algorithm="RS256")

def _get_access_token(self) -> str:
    url = f"{self.oauth_base_url}/oauth/token"
    assertion = self._create_jwt_assertion()

    data = {
        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
        "assertion": assertion,
    }

    response = requests.post(url, data=data, verify=self.verify_ssl)
    response.raise_for_status()
    token_data = response.json()

    access_token = str(token_data.get("access_token", ""))
    expires_in = int(str(token_data.get("expires_in", 3600)))
    self._access_token = access_token
    # 有効期限の300秒前から更新対象
    self._token_expires_at = time.time() + expires_in - 300
    return access_token

JWTペイロードの落とし穴

フィールドよくあるミス
issIntegration KeyAccount IDと混同する
subUser GUIDユーザー名やメールを入れてしまう
audaccount-d.docusign.com or account.docusign.comデモ/本番のマッピング間違い
scopesignature impersonationスペース区切り必須(カンマ区切りにしてしまう)
expnow + 3600長過ぎる値(最大1時間)

private_keyの改行問題

環境変数にPEM秘密鍵を入れる際、改行が\nにエスケープされているケースが多い。実際の改行に戻す処理が必要だ。

raw_key = private_key or env_setting.DOCUSIGN_PRIVATE_KEY
self.private_key = raw_key.replace("\\n", "\n") if raw_key else ""

Azure Key Vault/AWS Secrets Manager経由で取得するとこの問題は起きにくいが、.envaz webapp config appsettingsで設定する場合は必須の前処理。

レイヤー3: アカウントIDを含むURLパス設計

DocuSign REST APIの特徴は全てのエンドポイントがアカウントIDを含む点だ。/v2.1/accounts/{account_id}/envelopesのように。これを毎回呼び出し側で組み立てるのは冗長なので、_requestメソッド内で一元化する。

def _request(
    self,
    method: str,
    endpoint: str,
    params=None,
    json_data=None,
    return_raw: bool = False,
):
    # アカウントIDを含むURLを組み立て
    url = f"{self.base_url}/v2.1/accounts/{self.account_id}{endpoint}"
    headers = self._get_headers()

    response = requests.request(
        method=method, url=url, headers=headers,
        params=params, json=json_data, verify=self.verify_ssl,
    )

    # 429レート制限 → Retry-Afterに従ってリトライ
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        response = requests.request(
            method=method, url=url, headers=headers,
            params=params, json=json_data, verify=self.verify_ssl,
        )

    response.raise_for_status()
    return response.content if return_raw else response.json()

呼び出し側は/envelopesと書くだけで済み、アカウントIDやバージョンプレフィックスを意識しなくてよい。将来v2.1→v3.0に切り替える際もここ一箇所の修正で済む。

レイヤー4: 封筒作成の型付きヘルパー

DocuSign APIで最も使うのが封筒(Envelope)作成だ。生のAPIは柔軟性が高い代わりにペイロードが複雑なので、型付きラッパーを用意すると呼び出し側のコードが劇的に読みやすくなる。

def create_envelope(
    self,
    email_subject: str,
    documents: list[dict],
    recipients: dict[str, list[dict]],
    status: str = "sent",
    email_blurb: Optional[str] = None,
) -> dict:
    envelope_definition = {
        "emailSubject": email_subject,
        "documents": documents,
        "recipients": recipients,
        "status": status,
    }
    if email_blurb:
        envelope_definition["emailBlurb"] = email_blurb

    return self._request("POST", "/envelopes", json_data=envelope_definition)

ファイルから一発で封筒作成する簡易版

def create_envelope_from_file(
    self,
    email_subject: str,
    file_content: bytes,
    file_name: str,
    signer_email: str,
    signer_name: str,
    status: str = "sent",
) -> dict:
    import base64
    file_extension = file_name.split(".")[-1] if "." in file_name else "pdf"
    document_base64 = base64.b64encode(file_content).decode("utf-8")

    documents = [{
        "documentBase64": document_base64,
        "name": file_name,
        "fileExtension": file_extension,
        "documentId": "1",
    }]
    recipients = {
        "signers": [{
            "email": signer_email,
            "name": signer_name,
            "recipientId": "1",
            "routingOrder": "1",
        }]
    }
    return self.create_envelope(
        email_subject=email_subject,
        documents=documents,
        recipients=recipients,
        status=status,
    )

PDFファイルと署名者1名があれば、1関数呼び出しで封筒送信まで完了する。社内システムからの契約送信フローはほぼこの形で実装できる。

レイヤー5: 全封筒取得のページネーション(totalSetSize方式)

DocuSignのページネーションはCloudSignと異なり、totalSetSize文字列を返す独特な方式だ。ここを誤ると途中で打ち切られる。

def get_all_envelopes(
    self,
    from_date: Optional[str] = None,
    to_date: Optional[str] = None,
    status: Optional[str] = None,
    max_results: int = 10000,
) -> list[dict]:
    all_envelopes = []
    start_position = 0
    count = 100

    while len(all_envelopes) < max_results:
        response = self.get_envelopes(
            from_date=from_date,
            to_date=to_date,
            status=status,
            start_position=start_position,
            count=count,
        )

        envelopes = response.get("envelopes", [])
        if not envelopes:
            break

        for env in envelopes:
            if isinstance(env, dict):
                all_envelopes.append(env)

        # totalSetSizeは文字列で返ってくる(DocuSign仕様)
        total_set_size = response.get("totalSetSize", "0")
        if isinstance(total_set_size, str):
            total_set_size = int(total_set_size)
        if len(all_envelopes) >= total_set_size:
            break

        start_position += count

    return all_envelopes[:max_results]

ポイントはresultSetSize/totalSetSizeが文字列で返る仕様。JSONとして数値期待でintキャストせずに比較すると常にFalseとなり無限ループになる。

ダウンロードの特殊ID: "combined" と "certificate"

封筒内のドキュメントIDには予約語がある。

  • document_id="combined" — 封筒内の全ドキュメントを1つのPDFに結合してダウンロード
  • document_id="certificate" — 電子署名証明書(Certificate of Completion)のPDFダウンロード
  • document_id="1", "2", ... — 個別ドキュメント
def download_document(self, envelope_id: str, document_id: str) -> bytes:
    result = self._request(
        "GET",
        f"/envelopes/{envelope_id}/documents/{document_id}",
        return_raw=True,
    )
    return result

# 使用例
combined_pdf = client.download_document(env_id, "combined")
certificate_pdf = client.download_document(env_id, "certificate")

CloudSign vs DocuSign: 設計の違い

観点CloudSignDocuSign
認証client_id → tokenJWT Grant (RSA署名)
環境分離base_urlのみdemo/prod OAuth URL分離
URLパスシンプル(/documents)アカウントID込み(/v2.1/accounts/{id}/envelopes)
ページネーションpage/per_pagestart_position/count + totalSetSize(文字列)
証明書取得専用エンドポイント(/certificate)documentId="certificate"
結合PDF-documentId="combined"

両者を併用するハイブリッドな社内システムを作る場合、この違いを把握しておくと抽象化レイヤー設計が楽になる。

まとめ: DocuSign統合の5つの勘所

  1. base_urlからデモ/本番を自動判定する実装で環境取り違えを防ぐ
  2. JWT Grant認証はiss/sub/aud/scopeの取り違えが最頻バグ
  3. private_keyの改行エスケープ復元を初期化時に必ず実施
  4. アカウントIDを含むURLは_requestで一元化して呼び出し側を単純化
  5. totalSetSizeは文字列 — intキャスト必須でページネーション無限ループ回避

この設計でrenueは社内の契約フローにDocuSign統合を組み込んでいる。電子署名APIは一度きちんと組めば長期運用できるため、初回の設計投資を惜しまないことが重要だ。

よくある質問

Q. consent取得はどうすればいい?

初回のみhttps://account-d.docusign.com/oauth/auth?response_type=code&scope=signature%20impersonation&client_id={key}&redirect_uri={uri}にブラウザアクセスし、ユーザーが手動で承認する必要がある。これを忘れるとconsent_requiredエラーになる。

Q. CloudSignとDocuSignどちらがAPIで扱いやすい?

認証の簡潔さではCloudSign(client_id 1本)、機能の豊富さとグローバル対応ではDocuSign。国内法務中心ならCloudSign、海外取引を含むならDocuSignが選択肢になる。

Q. レート制限はどの程度?

DocuSignのAPIは1アカウントあたり1時間1000コール程度。429エラー時はRetry-Afterヘッダに従ってバックオフすればよい。