Send a document for signature with Node.js
This walks through a minimal, working Node.js example that uploads a PDF and creates a signature request through Esignapi's REST API — enough to send your first document programmatically and see exactly how the pieces fit together before you build a fuller integration around it.
What you need first
- An Esignapi account with API access (available starting on the Pro plan).
- An API key from your account settings, in the
key_id.key_secret format.
- A PDF file to send, and an email address you control to act as the test signer.
- Node.js with
fetch available (built in from Node 18 onward).
Step 1: upload the document
The API expects the file content base64-encoded, along with its filename and MIME type:
import { readFile } from "node:fs/promises";
const API_URL = "https://api.esignapi.me/v1";
const API_KEY = process.env.ESIGNAPI_KEY; // key_id.key_secret
async function uploadDocument(filePath, filename) {
const fileBuffer = await readFile(filePath);
const base64Content = fileBuffer.toString("base64");
const response = await fetch(`${API_URL}/upload-document`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
filename,
content: base64Content,
mime_type: "application/pdf",
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Upload failed: ${error.error || response.status}`);
}
return response.json();
}
Step 2: create the signature request
Once you have a document ID back from the upload, create the request with your signer list and any fields:
async function createSignatureRequest(documentId, signers) {
const response = await fetch(`${API_URL}/create-signature-request`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": API_KEY,
},
body: JSON.stringify({
document_id: documentId,
name: "Service Agreement",
message: "Please review and sign this document.",
signers,
expires_in_days: 30,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Request creation failed: ${error.error || response.status}`);
}
return response.json();
}
Putting it together
async function main() {
const { document_id } = await uploadDocument("./contract.pdf", "contract.pdf");
const request = await createSignatureRequest(document_id, [
{ email: "signer@example.com", name: "Jane Signer" },
]);
console.log("Signature request created:", request.id);
}
main().catch((err) => {
console.error(err.message);
process.exitCode = 1;
});
Running this sends the test signer a secure link to open and complete the document — the same kind of link they'd receive if the request had been created through the dashboard.
Error handling worth adding
The bare example above throws on any non-OK response, which is enough to see the flow working but not enough for production use. At minimum, check for the specific status codes that matter: a 429 means you've hit the rate limit and should back off before retrying (see Rate limits and best practices for e-signature APIs), while a 401 usually means the API key itself is wrong or revoked rather than something to retry. See Error handling and idempotency in e-signature APIs for a fuller breakdown of what to check for and how to structure retries.
Checking a request's status later
If you're not using a webhook, you can poll the request's status directly:
async function getRequestStatus(requestId) {
const response = await fetch(`${API_URL}/get-signature-request?id=${requestId}`, {
headers: { "x-api-key": API_KEY },
});
return response.json();
}
For most integrations, a webhook is the better long-term approach — see E-signature webhooks explained for the tradeoffs and an important limitation worth knowing about.
Keeping the API key out of your repository
Load the key from an environment variable, as the example above does with process.env.ESIGNAPI_KEY, rather than writing it directly into your source file — this keeps it out of version control and makes it easy to use a different key per environment.
Why base64 encoding for the document content
Sending the PDF as a base64-encoded string inside a JSON body, rather than as a raw file upload with a multipart form, keeps the request format consistent with every other JSON-based call the API uses, and avoids the extra complexity of multipart encoding in environments where that's awkward to set up. The tradeoff is that base64 encoding increases the payload size by roughly a third compared to the raw file, which is worth keeping in mind if you're uploading unusually large documents, though it rarely matters for a typical multi-page PDF contract.
Structuring this as a reusable module
The two functions shown above are a starting point, not a finished integration. In a real application, you'd typically wrap them in a small module that also handles configuration (reading the API key and base URL once, rather than scattering process.env lookups across your codebase), exposes a consistent error type for callers to catch, and optionally adds the retry and idempotency handling described in Error handling and idempotency in e-signature APIs. Keeping this logic in one place makes it easier to update later if you add more endpoints to your integration — status checks, downloads, or webhook handling — without duplicating the authentication and error-handling boilerplate in every file that needs to call the API.
Adding fields to the request
The minimal example above creates a signature request with no fields at all, which isn't actually useful on its own — a signer needs at least one positioned field (typically a signature field) to have anything to complete. A real call includes a fields array in the same create-signature-request body, with each field's page, coordinates, type, and assigned signer specified explicitly. See Placing signature fields in PDFs via API for the full structure, which plugs directly into the createSignatureRequest function shown here by adding a fields property to the request body.
Running this in a serverless function
If your integration runs inside a serverless function (an AWS Lambda, a Vercel or Netlify function) rather than a long-running server, the same code works largely unchanged, since each call is self-contained and doesn't depend on persistent in-memory state. The one thing worth checking is your function's timeout setting — uploading a larger PDF and waiting for the API's response needs enough time to complete within whatever execution limit your serverless platform enforces.
Using TypeScript instead of plain JavaScript
If your project is written in TypeScript, the same calls benefit from typing the response shapes rather than treating everything as any:
interface UploadResponse {
document_id: string;
}
interface SignatureRequestResponse {
id: string;
status: string;
}
async function uploadDocument(filePath: string, filename: string): Promise<UploadResponse> {
// same implementation as above, with a typed return
}
This doesn't change how the API behaves, but it does mean your editor and compiler catch a typo in a field name (document_id vs documentId) before it ever reaches a running request.
Running this from a framework route handler
If this code lives inside a Next.js API route, an Express handler, or similar, the same two functions work without modification — call them from inside your route handler, store the resulting request ID against whatever record in your own database represents this document, and respond to your frontend once the request has been created. Keep the API key read from environment configuration (process.env) rather than anything exposed to the client bundle, since a Next.js API route runs server-side but a page component does not.
A note on handling the API response shape
The examples above assume the response body, once parsed from JSON, matches the shape described in the documentation — but it's still worth defensively checking for the specific fields you need (document_id, id) rather than assuming they're always present, especially while you're still getting familiar with the API during initial development. A quick console.log(JSON.stringify(result, null, 2)) after your first few real calls is a simple way to confirm the actual shape of what comes back before you write code that assumes a specific structure.
Try it yourself
Esignapi's API documentation includes this full example alongside the request and response shapes for every endpoint referenced here.
Frequently asked questions
Does this example work with older versions of Node.js?
The built-in fetch function is available from Node 18 onward. For earlier versions, a library like node-fetch or axios provides the same basic HTTP functionality.
How do I know if the upload succeeded?
Check response.ok (or the status code directly) before trusting the response body — a failed request still usually returns a JSON body with an error field explaining what went wrong.
Can I send multiple signers in one request?
Yes, the signers array can contain any number of signer objects, each with at least an email, and the signing order (parallel or sequential) can be configured per signer if your document needs one.
What happens if I run this script twice by accident with the same document?
Each call creates its own signature request unless you add your own duplicate-prevention logic — see Error handling and idempotency in e-signature APIs for why Esignapi's own duplicate protection is a safety net rather than something to rely on as your primary control.
Where do I get the document_id to reuse on a later request?
The upload response includes it — store it in your own database if you plan to send the same document to multiple signers over time without re-uploading it each time.