DocuSign SOAP API to REST: migrate embedded signing without the rewrite
Your SOAP embedded-signing integration still works, but legacy auth is gone and the API is frozen. Map envelopes to documents, retire the token ceremony, and swap the SOAP client for one tested POST. Node.js examples included.
Akbar Ali · 17 October 2026
Somewhere in your deploy there's a folder nobody opens. It has a generated SOAP client, a WSDL snapshot, and a comment from 2013 that says "do not touch". It still sends envelopes. Every quarter, someone writes "migrate off SOAP" into a planning doc, and every quarter it loses to something shinier.
I get it. DocuSign still supports the eSignature SOAP API for existing integrations, so this isn't a funeral. But legacy username-and-password auth is dead for anything new since April 2022, the App Password workaround was always a stopgap, and the SOAP API is frozen while everything new lands on REST with OAuth 2.0. You're maintaining a museum exhibit that also happens to send your contracts.
The good news: the embedded-signing part of a SOAP integration is the easiest part to move. The concepts map almost one-to-one. The Node.js code below was tested locally on Node 22 against a mock API, including the error paths. The old SOAP snippets are condensed from real integrations to show the shape - they are not meant to run.
Trap 1: "Let's just fix the auth and stay"
This is the plan that keeps the migration on next quarter's list forever.
The old world had two ways in: the WS-Security UsernameToken on api.asmx, or the X-DocuSign-Authentication header with your email, password and integrator key on DSAPI.asmx. DocuSign blocked new go-lives on that legacy auth in April 2022 and required App Passwords by October 2022. On the REST side, none of it exists - OAuth 2.0 is the only door.
So "just fix the auth" means writing an OAuth flow to keep talking to an API that hasn't grown a feature in years. The auth was never the real bill. The real bill is the generated client, the XML faults, the runtime your SOAP stack pins you to, and the developer who has to learn WSDL to change a signature box.
Trap 2: assuming nothing maps over
Most SOAP integrations I see do exactly one thing: send a template, get a recipient token, open it in an iframe. That flow has a direct modern equivalent:
| SOAP world | Modern REST world |
|---|---|
CreateAndSendEnvelope | POST /v1/templates/:id/send |
Captive recipient with a ClientUserId | A recipient with embed: true on the send |
RequestRecipientToken + authentication assertion + security domain | Nothing. The send response already carries the signing URL |
Nine ClientURLs callbacks | Webhook events plus the embed SDK's onSigned / onDeclined |
X-DocuSign-Authentication header or UsernameToken | Authorization: Bearer with an API key |
| DocuSign Connect polling your listener | Signed webhooks you verify per event |
The biggest mental shift: there is no second call. The token ceremony - create the envelope, then ask for a recipient token with an assertion ID, an authentication instant and a security domain - collapses into the single send. The signing URL comes back in the same response.
Trap 3: the token ceremony
Here's what embedded signing used to take, condensed from a real integration:
// SOAP, condensed. Shows the ceremony - not meant to run.
$env->Recipients[0]->CaptiveInfo->ClientUserId = "User1";
$createResult = $api->CreateAndSendEnvelope($params);
$assertion = new RequestRecipientTokenAuthenticationAssertion();
$assertion->AssertionID = guid();
$assertion->AuthenticationInstant = nowXsdDate();
$assertion->AuthenticationMethod = "Password";
$assertion->SecurityDomain = "your app";
$urls = new RequestRecipientTokenClientURLs();
$urls->OnSigningComplete = $base . "?event=SignComplete";
$urls->OnViewingComplete = $base . "?event=ViewComplete";
$urls->OnCancel = $base . "?event=Cancel";
$urls->OnDecline = $base . "?event=Decline";
$urls->OnSessionTimeout = $base . "?event=Timeout";
$urls->OnTTLExpired = $base . "?event=TTLExpired";
$urls->OnIdCheckFailed = $base . "?event=IDCheck";
$urls->OnAccessCodeFailed = $base . "?event=AccessCode";
$urls->OnException = $base . "?event=Exception";
$response = $api->RequestRecipientToken($tokenParams);And the token you got back was short-lived, single-use, and iframe-only. Everyone who worked with this API has a story about a redirect that landed on "Session has expired".
The modern version is one function. This is the whole replacement, and it's the code I actually tested:
// signing.js - the two-step SOAP ceremony, replaced by one POST.
export async function createSigningSession({ apiKey, templateId, title, signer, fetchImpl = fetch }) {
const res = await fetchImpl(`https://putmysign.com/v1/templates/${templateId}/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
embed: true,
title,
recipients: [{ role_id: signer.roleId, email: signer.email, name: signer.name }],
}),
});
const body = await res.json();
if (!res.ok) {
// Switch on the machine-readable code, never the human message.
const code = body && body.error && body.error.code ? body.error.code : `http_${res.status}`;
const err = new Error(code);
err.code = code;
err.status = res.status;
throw err;
}
// Pick the session by the signer's email, never by array position.
// sessions[0] is not an access-control policy.
const sessions = (body.embed && body.embed.sessions) || [];
const session = sessions.find((s) => s.email === signer.email);
if (!session || !session.url) {
throw new Error("no_session_for_signer");
}
return { documentId: body.id, signingUrl: session.url };
}Two details carry over from the old world. The ClientUserId idea - "this URL belongs to this specific person" - is still real: pick the session by the signer's email and hand that person only their own URL. And embed URLs are still short-lived (30 minutes now), so the old habit of minting the token at the last responsible moment is still correct. Create the session when your user reaches the signing step, not at signup.
Trap 4: rebuilding the callback soup
Those nine ClientURLs were never events. They were redirect targets - the browser got bounced to your OnCancel page and you inferred what happened. If your migration plan includes a page per outcome, you're porting the wrong pattern.
The modern version is signed webhooks to one endpoint: recipient.viewed, recipient.signed, recipient.declined, document.completed. Verify the signature on the raw body before you trust any of it, and expect duplicates - retries are normal, so dedupe on the event ID. I wrote up the verification code in the webhook guide, and the browser-side onSigned callback is covered in the Next.js guide. Short version: onSigned moves your UI along, but whatever unlocks something real waits for the verified webhook on your server.
Trap 5: "we'll migrate next quarter"
The way out is not a rewrite. It's a seam:
- Put the new send behind whatever internal function your code already calls to start a signing. Your call sites keep their shape; only the guts change.
- Run new documents through it. A test key skips email entirely, so the first runs cost nothing and spam nobody.
- Leave in-flight envelopes alone. Keep the old read-only polling for documents already out for signature until they finish, then delete the SOAP client. Migrating a half-signed envelope is how you get two copies of the same contract.
That last point is the one that turns a quarter into an afternoon. You are not migrating history. You are changing where new documents go.
Try it on one document
Create a template in the dashboard, generate a test key under Developers, and send it from your server with embed: true. Mount the returned URL in your iframe or with the embed script, and sign it yourself. Test keys skip email, so this first run tests your payload, not inbox delivery.
Try PutMySign's signing API, or start with the API docs. If you're still weighing providers, the DocuSign API alternatives guide covers the landscape. And when the last in-flight SOAP envelope completes, you get to do the best part of any migration: delete the folder that says "do not touch".