DocuSign Embedded Signing with Elixir

View Source

Run in Livebook

This LiveBook demonstrates how to create a complete embedded signing experience with the DocuSign Elixir SDK. It walks you through the entire process from authentication to retrieving a signed document.

Mix.install([
  {:docusign, "~> 2.0.0"},
  {:kino, "~> 0.15.3"}
])

Introduction

This LiveBook demonstrates how to create an embedded signing experience with the DocuSign Elixir SDK. Embedded signing allows you to integrate the DocuSign signing process directly into your application, keeping users in your environment.

Configuration

First, let's set up our DocuSign configuration:

alias Kino.Input

# Set up input forms for configuration
private_key_input = Input.textarea("Private Key (PEM format)",
  placeholder: "Paste your RSA private key here including BEGIN and END markers",
  label: "Your DocuSign integration's private key")
client_id_input = Input.text("Client ID (Integration Key)",
  label: "Found in the DocuSign admin under Apps & Keys")
account_id_input = Input.text("Account ID",
  label: "Your DocuSign account ID (found under Apps & Keys)")
user_id_input = Input.text("User ID",
  label: "The API Username of the DocuSign user to impersonate")
is_sandbox_input = Input.checkbox("Use Sandbox", default: true)
return_url_input = Input.text("Return URL",
  label: "Where the signer will be redirected after signing",
  default: "https://www.docusign.com")

# Display input forms individually for proper rendering
  Kino.render(private_key_input)
Kino.render(client_id_input)
Kino.render(account_id_input)
Kino.render(user_id_input)
Kino.render(is_sandbox_input)
Kino.render(return_url_input)

Now let's configure the DocuSign client:

# Get values from inputs
private_key = Kino.Input.read(private_key_input)
client_id = Kino.Input.read(client_id_input)
account_id = Kino.Input.read(account_id_input)
user_id = Kino.Input.read(user_id_input)
is_sandbox = Kino.Input.read(is_sandbox_input)
return_url = Kino.Input.read(return_url_input)

# Fix key format if needed
private_key_formatted =
  private_key
  |> String.replace(~r/\r\n/, "\n") # Convert Windows line endings
  |> String.trim() # Remove leading/trailing whitespace

# Configure DocuSign application
hostname = if is_sandbox, do: "account-d.docusign.com", else: "account.docusign.com"

# Use runtime configuration instead of Mix.Config
Application.put_env(:docusign, :hostname, hostname)
Application.put_env(:docusign, :client_id, client_id)
Application.put_env(:docusign, :private_key_contents, private_key_formatted)

Create a Connection

Let's establish a connection to the DocuSign API:

# Create a connection with the specified user
connection_result = DocuSign.Connection.get(user_id)

conn = case connection_result do
  {:ok, connection} ->
    IO.puts("✅ Successfully connected to DocuSign")
    connection

  {:error, {:consent_required, consent_message}} when is_binary(consent_message) ->
    original_url = Regex.run(~r/https:\/\/[^\s]+/, consent_message) |> List.first()

    # Fix the URL if it's using the old redirect URI format
    url =
      if String.contains?(original_url, "redirect_uri=https://account") do
        String.replace(original_url,
                       ~r/redirect_uri=https:\/\/account[^&]+/,
                       "redirect_uri=https://www.docusign.com")
      else
        original_url
      end

    # Only use Kino.Markdown for cleaner output
    Kino.Markdown.new("""
    ## ⚠️ DocuSign User Consent Required

    Before using the API, you need to grant consent for this application to act on your behalf.

    1. **Click this link**: [Grant Consent](#{url})
    2. Sign in to DocuSign if prompted
    3. Review and click "ALLOW ACCESS"
    4. Return here and run this cell again

    This is a one-time process per user.

    > **Note**: If you're seeing redirect URI errors, ensure your DocuSign application has
    > `https://www.docusign.com` configured as a redirect URI.
    """) |> Kino.render()
    
    nil # Return nil as conn value to avoid undefined variable

  {:error, reason} ->
    IO.puts("❌ Failed to connect to DocuSign")
    IO.inspect(reason, label: "Error")
    nil # Return nil as conn value to avoid undefined variable
end

Set Up Recipient Information

Let's create a form to input the recipient's information:

# Verify that conn is available from previous cell
conn = conn || (
  IO.puts("⚠️ Connection not established. Please run the previous cell to connect to DocuSign.")
  nil
)

recipient_name_input = Input.text("Recipient Name")
recipient_email_input = Input.text("Recipient Email")
client_user_id_input = Input.text("Client User ID", default: "1001",
  placeholder: "Unique identifier for the recipient")

# Render each input individually for proper display
Kino.render(recipient_name_input)
Kino.render(recipient_email_input)
Kino.render(client_user_id_input)

Create a Document

Let's create a simple document for signing:

# Create a simple HTML document
html_document = """
<!DOCTYPE html>
<html>
<head>
  <title>DocuSign Embedded Signing Example</title>
  <style>
    body { font-family: Arial, sans-serif; margin: 40px; }
    .header { text-align: center; margin-bottom: 30px; }
    .content { margin-bottom: 50px; }
    .signature-section { margin-top: 50px; border-top: 1px solid #ccc; padding-top: 20px; }
  </style>
</head>
<body>
  <div class="header">
    <h1>Embedded Signing Agreement</h1>
    <p>Created with DocuSign Elixir SDK in LiveBook</p>
  </div>

  <div class="content">
    <p>This document demonstrates embedded signing with the DocuSign Elixir SDK.</p>
    <p>By signing this document, the signer acknowledges that:</p>
    <ul>
      <li>They have read and understood the embedded signing process</li>
      <li>They agree to the terms outlined in this document</li>
      <li>This is a demonstration of embedded signing capabilities</li>
    </ul>
  </div>

  <div class="signature-section">
    <p>Signed by: <span style="text-decoration: underline; padding: 0 100px;">____________________</span></p>
    <p>Date: <span style="text-decoration: underline; padding: 0 100px;">____________________</span></p>
  </div>
</body>
</html>
"""

# Base64 encode the document
encoded_document = Base.encode64(html_document)

Create an Envelope for Embedded Signing

Now let's create an envelope specifically for embedded signing. For embedded signing, we must:

  1. Create an envelope with a recipient that has a clientUserId (this identifies the recipient as embedded)
  2. Set the envelope status to "created" (not "sent")
recipient_name = Kino.Input.read(recipient_name_input)
recipient_email = Kino.Input.read(recipient_email_input)
client_user_id = Kino.Input.read(client_user_id_input)

# Use DocuSign SDK models for proper object creation

# Create document object
document = %DocuSign.Model.Document{
  documentBase64: encoded_document,
  name: "Embedded Signing Example.html",
  fileExtension: "html",
  documentId: "1"
}

# Create the signer with signature and date tabs
sign_here_tab = %DocuSign.Model.SignHere{
  anchorString: "Signed by:", # More specific anchor text
  anchorUnits: "pixels",
  anchorYOffset: "-5", # Slightly negative to position just above the line
  anchorXOffset: "110", # Slightly increased from original 100
  documentId: "1", # Match the document ID
  recipientId: "1" # Match the recipient ID
}

date_signed_tab = %DocuSign.Model.DateSigned{
  anchorString: "Date:", # Matches text in our document
  anchorUnits: "pixels",
  anchorYOffset: "-10", # Keep negative value to position above the line
  anchorXOffset: "80", # Increased from 50 to better center the date
  documentId: "1", # Match the document ID
  recipientId: "1" # Match the recipient ID
}

# Create the signer object with signature and date tabs
signer = %DocuSign.Model.Signer{
  email: recipient_email,
  name: recipient_name,
  recipientId: "1",
  routingOrder: "1",
  clientUserId: client_user_id, # This identifies the recipient as embedded
  tabs: %DocuSign.Model.Tabs{
    signHereTabs: [sign_here_tab],
    dateSignedTabs: [date_signed_tab]
  }
}

# Create envelope definition with status "sent"
envelope_definition = %DocuSign.Model.EnvelopeDefinition{
  emailSubject: "Please sign this document (Embedded Signing)",
  documents: [document],
  recipients: %DocuSign.Model.Recipients{
    signers: [signer]
  },
  status: "sent"  # Important: For embedded signing, use "sent" (not "created")
}

# Create the envelope using the standard API - note we use body: instead of envelopeDefinition:
result = DocuSign.Api.Envelopes.envelopes_post_envelopes(conn, account_id, body: envelope_definition)

# Create a variable to hold the envelope ID
envelope_id = 
  try do
    case result do
      {:ok, response} ->
        id = response.envelopeId
        IO.puts("✅ Envelope created successfully!")
        IO.puts("Envelope ID: #{id}")
        
        # Return the envelope ID
        id

      {:error, error} ->
        IO.puts("❌ Failed to create envelope")
        IO.inspect(error, label: "Error details:")
        nil
    end
  rescue
    e in KeyError ->
      IO.puts("⚠️ Received error response with unexpected format")
      IO.inspect(e, label: "KeyError details")
      IO.inspect(result, label: "Full response")
      nil
  end

Create the Embedded Signing URL

Now that we have created the envelope, we need to generate a URL that will allow the recipient to sign the document within your application:

# Verify that conn is available from previous cells
conn = conn || (
  IO.puts("⚠️ Connection not established. Please run the previous cells to connect to DocuSign.")
  nil
)

# Check if envelope_id is available from previous cell
# If not defined in previous cell execution, we'll let the user input it

envelope_id_input =
  if envelope_id do
    Input.text("Envelope ID", default: envelope_id)
  else
    Input.text("Envelope ID")
  end

Kino.render(envelope_id_input)
signing_envelope_id = Kino.Input.read(envelope_id_input)

# Use client_user_id from previous cell if available
client_user_id_to_use = client_user_id

# Create the recipient view request properly using the DocuSign SDK model
recipient_view_request = %DocuSign.Model.RecipientViewRequest{
  authenticationMethod: "none",
  clientUserId: client_user_id_to_use, # Must match the clientUserId used in envelope creation
  recipientId: "1", # Must match the recipientId used in envelope creation
  returnUrl: return_url,
  userName: recipient_name,
  email: recipient_email
}

IO.puts("\nCreating recipient view URL for:")
IO.puts("- Envelope ID: #{signing_envelope_id}")
IO.puts("- Client User ID: #{client_user_id_to_use}")
IO.puts("- Return URL: #{return_url}")

# Get the recipient view URL using the standard API
result = DocuSign.Api.EnvelopeViews.views_post_envelope_recipient_view(
  conn,
  account_id,
  signing_envelope_id,
  body: recipient_view_request
)

# Create a frame at the beginning to hold the signing URL
signing_frame = Kino.Frame.new() |> Kino.render()

try do
  case result do
    {:ok, view_response} ->
      signing_url = view_response.url
      IO.puts("✅ Embedded signing URL generated successfully!")

      # Render the HTML into the existing frame (replaces content instead of duplicating)
      Kino.Frame.render(signing_frame, Kino.HTML.new("""
      <div style="margin: 20px 0; padding: 15px; border: 1px solid #e0e0e0; border-radius: 5px; background-color: #f9f9f9;">
        <h3 style="margin-top: 0;">Embedded Signing URL Generated</h3>
        <p>Click the button below to open the DocuSign signing experience in a new tab:</p>
        <a href="#{signing_url}" target="_blank" style="display: inline-block; background: #2F80ED; color: white;
           padding: 10px 20px; text-decoration: none; border-radius: 4px; font-weight: bold;">
          Open Signing Experience
        </a>
      </div>
      """))

    {:error, error} ->
      IO.puts("❌ Failed to generate embedded signing URL")
      IO.inspect(error, label: "Error details:")
  end
rescue
  e in KeyError ->
    IO.puts("⚠️ Received error response with unexpected format")
    IO.inspect(e, label: "KeyError details")
    IO.inspect(result, label: "Full response")
end

Envelope Status and Document Retrieval

After the recipient completes the signing process, the system will automatically check the envelope status and retrieve the completed document if available.

# Create status frame to display envelope status
status_frame = Kino.Frame.new() |> Kino.render()

# Create document frame to display retrieved document
document_frame = Kino.Frame.new() |> Kino.render()

# Verify that conn is available from previous cells
conn = conn || (
  IO.puts("⚠️ Connection not established. Please run the previous cells to connect to DocuSign.")
  nil
)

# Function to check status and update UI
check_envelope_status = fn ->
  # Get envelope status using DocuSign client library
  case DocuSign.Api.Envelopes.envelopes_get_envelope(conn, account_id, signing_envelope_id) do
    {:ok, envelope} ->
      status = envelope.status

      status_description = case status do
        "created" -> "The envelope has been created, but not yet signed."
        "sent" -> "The envelope has been sent to the recipient, but not yet signed."
        "delivered" -> "The envelope has been delivered to the recipient, but not yet signed."
        "completed" -> "The envelope has been signed by all recipients."
        "declined" -> "The envelope has been declined by at least one recipient."
        "voided" -> "The envelope has been voided."
        _ -> "The envelope is in an unknown state."
      end

      # Update status frame with current status
      Kino.Frame.render(status_frame, Kino.Markdown.new("""
      ### Current Envelope Status: #{status}

      #{status_description}
      """))

      # Get recipient information
      case DocuSign.Api.EnvelopeRecipients.recipients_get_recipients(conn, account_id, signing_envelope_id) do
        {:ok, recipients} ->
          if recipients.signers && length(recipients.signers) > 0 do
            # Create a table of recipient info
            table = recipients.signers
            |> Enum.map(fn signer ->
              %{
                name: signer.name,
                email: signer.email,
                status: signer.status,
                delivered: signer.deliveredDateTime || "N/A",
                signed: signer.signedDateTime || "N/A"
              }
            end)
            |> Kino.DataTable.new()

            # Add the table to the status frame
            Kino.Frame.append(status_frame, table)
          end

        {:error, _} ->
          Kino.Frame.append(status_frame, Kino.Text.new("Could not retrieve recipient information"))
      end

      # If envelope is completed, automatically retrieve the document
      if status == "completed" do
        # Get the document using the DocuSign client library
        result = DocuSign.Api.EnvelopeDocuments.documents_get_document(
          conn,
          account_id,
          "1", # Document ID
          signing_envelope_id,
          decode_body: false  # Get raw response to handle PDF
        )

        case result do
          {:ok, %Tesla.Env{status: 200, body: document_content}} ->
            # Check content type from headers
            content_type =
              result
              |> elem(1)
              |> Map.get(:headers)
              |> Enum.find(fn {k, _v} -> String.downcase(k) == "content-type" end)
              |> elem(1)

            cond do
              # For HTML content, display in iframe
              String.contains?(content_type, "html") ->
                Kino.Frame.render(document_frame, Kino.HTML.new("""
                <div>
                  <h3>✅ Document Retrieved Successfully</h3>
                  <iframe
                    srcdoc="#{String.replace(document_content, "\"", "&quot;")}"
                    style="width: 100%; height: 500px; border: 1px solid #ccc;">
                  </iframe>
                </div>
                """))

              # For PDF content, create a download link
              String.contains?(content_type, "pdf") ->
                pdf_base64 = Base.encode64(document_content)

                Kino.Frame.render(document_frame, Kino.HTML.new("""
                <div>
                  <h3>✅ PDF Document Retrieved</h3>
                  <a href="data:application/pdf;base64,#{pdf_base64}" download="signed_document.pdf"
                     style="display:inline-block; background:#4CAF50; color:white; padding:10px 15px;
                     text-decoration:none; border-radius:4px; margin-top:10px;">
                    Download Signed PDF
                  </a>
                </div>
                """))

              # For other content types
              true ->
                Kino.Frame.render(document_frame, Kino.Text.new("Document retrieved but cannot be displayed (#{content_type})"))
            end

          {:error, error} ->
            Kino.Frame.render(document_frame, Kino.Text.new("❌ Failed to retrieve document: #{inspect(error)}"))
        end
      end

      # Return the status for potential further processing
      {:ok, status}

    {:error, error} ->
      Kino.Frame.render(status_frame, Kino.Text.new("❌ Failed to retrieve envelope status: #{inspect(error)}"))
      {:error, error}
  end
end

# Check status immediately on cell execution
check_envelope_status.()

# Create a manual refresh button (more compatible with Kino 0.10.0)
refresh_button = Kino.Control.button("Refresh Status")
Kino.render(refresh_button)
Kino.listen(refresh_button, fn _ -> check_envelope_status.() end)

Conclusion

This LiveBook demonstrates how to create a complete embedded signing experience with the DocuSign Elixir SDK.

You've learned how to:

  1. Create an envelope with a recipient specifically marked for embedded signing
    • Use status="sent" with a clientUserId to enable embedded signing
  2. Generate an embedded signing URL
  3. Automatically monitor the envelope status and retrieve the completed document

In a real application, you would typically:

  1. Create the envelope with a recipient marked for embedded signing (status="sent" and include clientUserId)
  2. Generate the embedded signing URL
  3. Redirect the user to the URL or embed it in an iframe
  4. Handle the redirect back to your application after signing
  5. Check the status of the envelope and retrieve the completed documents

For more information, check out the DocuSign Elixir GitHub repository and the official DocuSign API documentation.