This is the setup we use when an application needs people to sign a document without leaving it. Your server prepares the agreement, the user signs inside your page, and your server keeps DocuSign’s signed copy and completion certificate as evidence. It covers the DocuSign account configuration, the four API calls that do the work, how to tell whether someone actually signed, and what changes when you go live. The code is Node.js with plain fetch, but the calls are the same in any language.
What you are building
There are two choices to make up front.
Embedded or remote signing. In remote signing, DocuSign emails the signer a link and they sign on DocuSign’s site. In embedded signing, your application authenticates the user, asks DocuSign for a short-lived signing URL, and shows it in your page. Choose embedded when signing is a step in your own workflow, such as onboarding or checkout, and you already know who the user is.
JWT Grant or Authorization Code Grant. Authorization Code Grant makes each user sign in to DocuSign. JWT Grant lets your server act as one DocuSign user, a service account, without anyone signing in. For embedded signing driven by your backend, JWT Grant is the usual fit. The people signing don’t need DocuSign accounts at all.
The flow:
- Your server gets an access token with a signed JWT.
- It creates an envelope containing your document and one embedded signer.
- It asks for a recipient view, a single-use signing URL, and sends the user there.
- DocuSign returns the user to your app. Your server checks the envelope’s status, then downloads the signed document and the completion certificate.
Step 1: Set up a developer account
Start in a free DocuSign developer account. It is a sandbox on the demo environment, and integration keys are created here first, even ones that will end up in production.
In the developer account, go to Settings → Apps and Keys and note the account details at the top:
- User ID, a GUID: the user your integration will act as.
- API Account ID, a GUID. It is not the shorter account number shown elsewhere.
Then choose Add App and Integration Key, and name it something that says what it is for. On the app’s page:
- Copy the Integration Key (a GUID).
- Under authentication, choose Generate RSA. The private key is shown once. Save the whole block, including the
BEGINandENDlines and the line breaks, straight to a file. - Add a redirect URI, any HTTPS URL on your domain. JWT Grant never redirects, but the one-time consent step below won’t work without one.
For the user, use a service account you control, not a named employee. Every envelope is sent as that user, and if they leave, your integration stops working.
Step 2: Store the configuration
Four values drive the integration. Only one of them is secret:
| Value | Secret? | Where it goes |
|---|---|---|
| Integration key | No | Configuration |
| User ID | No | Configuration |
| API account ID | No | Configuration |
| RSA private key | Yes | Your secret store only |
Load the private key file straight into your secret store, delete the local copy, then read it back and check that the line breaks survived. A PEM flattened to one line fails to sign, and the error doesn’t mention the format.
Add a fifth setting for the environment, demo or production, and validate all of them at start-up. A missing value should stop the service with an error that names the value, not fail halfway through someone’s signing session.
const OAUTH_HOSTS = {
demo: 'account-d.docusign.com',
production: 'account.docusign.com',
};
export function loadConfig(env = process.env) {
const cfg = {
integrationKey: env.DOCUSIGN_INTEGRATION_KEY?.trim(),
userId: env.DOCUSIGN_USER_ID?.trim(),
accountId: env.DOCUSIGN_ACCOUNT_ID?.trim(),
privateKey: env.DOCUSIGN_PRIVATE_KEY,
environment: (env.DOCUSIGN_ENVIRONMENT || 'demo').trim(),
};
const missing = Object.entries(cfg).filter(([, v]) => !v).map(([k]) => k);
if (!OAUTH_HOSTS[cfg.environment]) missing.push('environment (demo|production)');
if (missing.length) throw new Error(`DocuSign config missing: ${missing.join(', ')}`);
return { ...cfg, oauthHost: OAUTH_HOSTS[cfg.environment] };
}
Step 3: Grant consent, once
JWT Grant can’t act as a user until that user has consented to the scopes. This is a one-time step in a browser; no API call can do it. Open this URL while signed in to DocuSign as the user from Step 1, and accept:
https://account-d.docusign.com/oauth/auth
?response_type=code
&scope=signature%20impersonation
&client_id=<INTEGRATION_KEY>
&redirect_uri=<REDIRECT_URI>
The browser lands on your redirect URI afterwards. You can ignore the code it carries.
Consent is per environment. You will do this again on account.docusign.com when you go live. If your production account is managed by an organization, ask its administrator to grant administrative consent instead. That covers all users and survives the service account changing.
Step 4: Get an access token
Build a JWT signed with RS256, then exchange it at the OAuth host’s token endpoint:
import crypto from 'node:crypto';
const b64url = (data) => Buffer.from(data).toString('base64url');
export async function getAccessToken(cfg) {
const now = Math.floor(Date.now() / 1000);
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const claims = b64url(JSON.stringify({
iss: cfg.integrationKey,
sub: cfg.userId,
aud: cfg.oauthHost,
iat: now,
exp: now + 3600,
scope: 'signature impersonation',
}));
const signature = crypto.createSign('RSA-SHA256')
.update(`${header}.${claims}`)
.sign(cfg.privateKey);
const assertion = `${header}.${claims}.${b64url(signature)}`;
const res = await fetch(`https://${cfg.oauthHost}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion,
}),
});
const body = await res.json();
if (!res.ok) {
// Log the status and error code only. Never log the body or the assertion.
const err = new Error(`DocuSign token request failed: ${res.status} ${body.error ?? ''}`);
err.code = body.error;
throw err;
}
return { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
}
Cache the token and request a new one shortly before expiresAt, instead of fetching one per request.
If this call fails, the error code is your main clue:
consent_requiredis good news. Your key and user ID are right; go back to Step 3.invalid_grantcovers several different mistakes and doesn’t say which. We cover how to work through it in DocuSign JWT Grant: the failures you will actually hit.
Step 5: Find the account’s API host
Don’t hardcode the REST host. Production accounts live on different regional servers, so ask DocuSign where the account lives:
export async function getApiBase(cfg, token) {
const res = await fetch(`https://${cfg.oauthHost}/oauth/userinfo`, {
headers: { authorization: `Bearer ${token}` },
});
const info = await res.json();
const account = info.accounts.find((a) => a.account_id === cfg.accountId);
if (!account) throw new Error('Configured account ID is not available to this user');
return `${account.base_uri}/restapi/v2.1/accounts/${cfg.accountId}`;
}
Cache the result. It changes only if the account moves.
Step 6: Prepare the document
Render the agreement on your server as a PDF. Mark where the signature goes with an anchor string, a short unique piece of text such as /sig1/. Render it in white so the signer doesn’t see it. DocuSign places the signature field wherever the anchor appears, so you don’t need to calculate page coordinates, and the layout can change without breaking signing.
Before sending, hash the PDF (SHA-256) and store the hash. That records exactly what you asked the person to sign.
Step 7: Create the envelope
An envelope is the container for the documents, the signers and their fields. Setting clientUserId on the signer is what makes the signing embedded. Without it, DocuSign emails the signer, which is a different product. Make it a required argument in your code:
export async function createEnvelope(apiBase, token, { pdf, signer }) {
if (!signer.clientUserId) throw new Error('clientUserId is required for embedded signing');
const res = await fetch(`${apiBase}/envelopes`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({
emailSubject: 'Please sign your agreement',
status: 'sent',
documents: [{
documentId: '1',
name: 'Agreement',
fileExtension: 'pdf',
documentBase64: pdf.toString('base64'),
}],
recipients: {
signers: [{
recipientId: '1',
email: signer.email,
name: signer.name,
clientUserId: signer.clientUserId, // your user's ID; makes this embedded
tabs: {
signHereTabs: [{ anchorString: '/sig1/', anchorUnits: 'pixels', anchorXOffset: '0', anchorYOffset: '0' }],
},
}],
},
}),
});
if (!res.ok) throw new Error(`createEnvelope failed: ${res.status}`);
const { envelopeId } = await res.json();
return envelopeId;
}
Store the envelopeId against your user straight away, before anything else can fail.
Step 8: Show the signing view
Ask for a recipient view. email, userName and clientUserId must match the signer exactly as they appear on the envelope:
export async function getSigningUrl(apiBase, token, { envelopeId, signer, returnUrl }) {
const res = await fetch(`${apiBase}/envelopes/${envelopeId}/views/recipient`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({
returnUrl, // must be an absolute https URL
authenticationMethod: 'none', // records how your app authenticated the user
email: signer.email,
userName: signer.name,
clientUserId: signer.clientUserId,
}),
});
if (!res.ok) throw new Error(`recipient view failed: ${res.status}`);
return (await res.json()).url;
}
Three things to know:
- The URL is short-lived and single use. Create it at the moment the user clicks “Sign”, not when you build the page.
returnUrlmust be absolute. A relative URL gets a bare400. Build it from your environment’s configured public origin, so one environment can never send users to another.- If you frame the view, update your Content Security Policy. The signing URL redirects between DocuSign hosts, so
frame-srcmust allow both hosts for each environment:demo.docusign.netandapps-d.docusign.comfor demo,www.docusign.netandapps.docusign.comfor production. Missing one gives a blank panel. Redirecting the whole page instead of framing avoids the issue.
Step 9: Confirm the signature
When the signer finishes, DocuSign sends the browser to your returnUrl with an event query parameter, such as signing_complete, cancel, decline or ttl_expired. Use it to decide what to show the user, and never as proof of signing. It is a value in a URL that anyone can type.
Your server confirms with DocuSign directly:
export async function getEnvelopeStatus(apiBase, token, envelopeId) {
const res = await fetch(`${apiBase}/envelopes/${envelopeId}`, {
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`status check failed: ${res.status}`);
const env = await res.json();
return { status: env.status, completedAt: env.completedDateTime ?? null };
}
Only completed means every signer has signed. Users close tabs before the redirect, so add a back-up: a scheduled job that checks envelopes still waiting on a signature, or a DocuSign Connect webhook (enable HMAC signing and verify it on every message).
Step 10: Keep the evidence
Once the envelope is completed, download two things:
- The combined document,
GET .../envelopes/{id}/documents/combined: the signed PDF. - The completion certificate,
GET .../envelopes/{id}/documents/certificate: DocuSign’s record of who signed, when, from where and how they were authenticated. This is the part you can’t produce yourself, and usually the reason to use a provider at all.
Hash both, store them in private storage, and serve them only through short-lived signed URLs. A minimal record:
| Field | Purpose |
|---|---|
envelope_id | Links your record to DocuSign’s |
status, completed_at | From the API, not the redirect |
sent_pdf_sha256 | What you asked them to sign |
signed_pdf_sha256, certificate_sha256 | What came back |
| Storage paths | Where the evidence lives |
If downloading or storing either file fails, don’t mark the agreement executed. Retry until both are safely stored.
Step 11: Go live
Nothing built against the demo environment works in production until the integration key has been through DocuSign’s go-live review:
- Pass the review. It runs against your integration’s API traffic in the developer account. Check DocuSign’s current go-live requirements before you plan around them.
- Promote the key. In the developer account, under Apps and Keys, choose Select Go-Live Account for the key and pick a paid production account. The key is copied there; the demo copy keeps working.
- Re-add production settings. On the production copy of the key, add the redirect URI again and generate a production RSA keypair. Store the new private key in your production secret store.
- Collect the production IDs. The user ID and API account ID in production are different GUIDs from the demo ones.
- Grant consent on the production host,
account.docusign.com, or use administrative consent. - Switch the environment setting to
production. If you followed Step 5, the API host takes care of itself. - Set the account’s branding. Signers see the DocuSign account’s company name in the signing view. Make sure it is a name they recognize.
- Update your privacy notice. DocuSign now processes signers’ personal data, so name it as a processor, say where it processes data, and cover the evidence in your retention schedule.
Before you rely on production, run a read-only check from inside the production environment: confirm every value is present without printing it, request a token, and call userinfo. A successful deploy only proves the values are configured. This check proves DocuSign accepts them.
Checklist
- Service-account user, not a person
- Private key only in the secret store, with line breaks checked
- Configuration validated at start-up, naming any missing value
- Consent granted in each environment
- Tokens cached and refreshed before expiry
- API host read from
userinfo, not hardcoded -
clientUserIdrequired on every embedded signer - Absolute
returnUrlbuilt from configuration - CSP allows both DocuSign hosts, if framing
- Signing confirmed by API status, never by the redirect
- Combined PDF and certificate downloaded, hashed and stored
- Errors log status and code only, never bodies or tokens
- Go-live done, production key configured, branding and privacy notice updated
How we can help
We build signing, identity and payment integrations into applications that have to stand up to an audit. If you are adding e-signature to a product, or one you already have isn’t producing evidence you would want to show a client, talk to us.