Contracts (v2)
The v2 contract API sends the current contract on a LightReach account to the end user for signature. It replaces the legacy v1 synchronous send with an asynchronous flow: the send is accepted immediately (202), the contract moves into a transient sending state, and the real work — compiling documents and dispatching the DocuSign envelope — happens in the background. You then track completion via a progress stream or webhooks.
This guide assumes you already have an access token, an account with an active quote, and an approved (or approved-with-stipulations) credit application. If you don’t, start with the Getting Started guide.
Why v2
Section titled “Why v2”v1 (/api/accounts/:accountId/contracts/...) | v2 (/api/v2/accounts/:accountId/contracts/...) | |
|---|---|---|
| Send | Synchronous — the request blocks until the envelope is dispatched, then returns the contract with status: sent | Asynchronous — returns 202 immediately with status: sending; completion tracked via SSE or webhooks |
| Concurrency | No guard — parallel sends could race | A per-account lock rejects a second concurrent send with 409 |
| Signing link | POST .../contracts/current/signing-link with a returnUrl body | GET .../contracts/current/signing-url?returnUrl=... |
v1 still works and is documented in Getting Started. Build new integrations on v2: the synchronous v1 send holds a connection open for the full DocuSign round-trip, which is slow and fragile for large contracts, whereas v2 returns instantly and lets you react to the outcome out of band.
Base path and authentication
Section titled “Base path and authentication”All endpoints are rooted at:
${envBaseUrl}/api/v2/accounts/{accountId}/contractsenvBaseUrl depends on your environment — see Environments. Every request must carry your bearer token:
headers: { Authorization: `Bearer ${accessToken}`,}Requests are scoped to the account in the path. A token that cannot access {accountId} receives a 403.
The endpoints act on the account’s current contract — the one tied to its active quote. You do not address contracts by ID; current always resolves to the contract that would be sent next.
Sending a contract
Section titled “Sending a contract”POST /api/v2/accounts/{accountId}/contracts/current/send
Sends (or resends) the current contract asynchronously. The body is optional.
import fetch from 'node-fetch';
const response = await fetch( `${envBaseUrl}/api/v2/accounts/${accountId}/contracts/current/send`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ externalReference: 'your-system-id-123', // optional }), },);
const contract = await response.json();// response.status === 202, contract.status === 'sending'Request body (optional — may be omitted or null)
| Field | Type | Notes |
|---|---|---|
externalReference | string | Your own identifier for the contract. It’s stored on the contract and echoed as contractReference in every contract webhook, so you can correlate events back to your system. |
Query parameters
| Param | Type | Notes |
|---|---|---|
force | boolean | Forces a resend even when the current contract would otherwise be treated as already handled. Honored only for internal LightReach admin/editor users; ignored for standard integrator tokens. Defaults to false. |
Preconditions
Section titled “Preconditions”The send validates the account before dispatching. If any check fails you get a 400 (see Errors). At a minimum the account needs:
- An active quote whose monthly payment stays within the account’s payment cap.
- A credit application that is approved or approved with stipulations.
- All send requirements satisfied and the organization’s account-progress rules met.
- For solar accounts in jurisdictions where it’s enforced, annual electricity consumption (
electricityUsageKwh) must be present.
Responses
Section titled “Responses”| Status | Meaning |
|---|---|
202 Accepted | The send was accepted. The returned contract has status: sending. Track completion via the progress stream or webhooks. This is the normal path. |
200 OK | The current contract was already in a completed state that satisfied the request, so nothing new was sent. The existing contract is returned as-is. |
400 Bad Request | A precondition failed (no valid documents to send, payment cap exceeded, missing annual consumption, etc.). |
409 Conflict | A send for this account is already in progress. Wait for it to finish (watch the progress stream or webhooks) before retrying. |
Contract shape
Section titled “Contract shape”Both 202 and 200 return a contract object:
{ id: '64a8394a449225deda077efa', accountId: '64a822dd2238174defd6b09c', quoteId: '64a8394a449225deda077ef9', externalReference: 'your-system-id-123', status: 'sending', // see "Contract statuses" below contractDocuments: [ { references: [{ name: 'accountId', value: '64a822dd2238174defd6b09c' }], type: 'esignature', provider: 'docusign', providerData: { templateId: '17c62d0a-098f-4a98-98aa-2c71d2630e38', envelopeId: '00270084-bb84-4384-a69c-78460dd370f0', }, status: 'created', recipients: [ ], }, ], // `url` is present only on responses that carry a signing link; see below.}Tracking send progress
Section titled “Tracking send progress”Because the send is asynchronous, a 202 means “accepted,” not “sent.” You have two ways to learn the outcome — a live stream and webhooks. Webhooks are the authoritative signal; use the stream for real-time UI feedback.
Server-Sent Events
Section titled “Server-Sent Events”GET /api/v2/accounts/{accountId}/contracts/current/send/progress
Opens an SSE stream of progress events for the account and sends a heartbeat comment every 30 seconds. Each message looks like:
{ accountId: '64a822dd2238174defd6b09c', contractId: '64a8394a449225deda077efa', status: 'sending', // 'sending' | 'sent' | 'failed' error: 'reason', // present only when status === 'failed'}The stream closes once it emits a terminal event (sent or failed).
import EventSource from 'eventsource';
const url = `${envBaseUrl}/api/v2/accounts/${accountId}/contracts/current/send/progress`;const sse = new EventSource(url, { headers: { Authorization: `Bearer ${accessToken}` } });
const result = await new Promise((resolve, reject) => { sse.onmessage = (e) => { const event = JSON.parse(e.data); if (event.status === 'sent') { sse.close(); resolve(event); } else if (event.status === 'failed') { sse.close(); reject(new Error(event.error)); } }; sse.onerror = () => { sse.close(); reject(new Error('SSE connection error')); };});Webhooks
Section titled “Webhooks”If you already consume webhooks, you don’t need the stream at all — the contract lifecycle is fully covered by events:
| Event | When |
|---|---|
contractSending | The async send was accepted and is being dispatched. |
contractSent | The contract was sent to the consumer for signature. This is the completion signal for a send. |
contractSigned | The consumer signed the contract. |
contractApproved | LightReach approved the signed contract. |
contractVoided / contractReinstated | The contract was voided, then possibly reinstated. |
Every contract webhook carries the externalReference you supplied at send time as contractReference. See the Webhooks guide for full payloads.
Creating a signing link
Section titled “Creating a signing link”GET /api/v2/accounts/{accountId}/contracts/current/signing-url?returnUrl={url}
Once a contract has been sent, generate a DocuSign recipient-view URL so the signer can open the document directly. The account holder also receives an email with their own signing link — generating one here doesn’t affect that.
| Query param | Type | Required | Notes |
|---|---|---|---|
returnUrl | string | yes | Where DocuSign redirects the signer after they finish signing. |
import fetch from 'node-fetch';
const res = await fetch( `${envBaseUrl}/api/v2/accounts/${accountId}/contracts/current/signing-url` + `?returnUrl=${encodeURIComponent('https://your-app.example.com/signed')}`, { headers: { Authorization: `Bearer ${accessToken}` } },);
const { url } = await res.json();Response
{ url: 'https://docusign.com/link-to-sign-document' }The link is single-use and expires after 5 minutes, so generate it on demand — don’t store or forward it. A 404 means there’s no current sendable contract for the account (for example, nothing has been sent yet).
Contract statuses
Section titled “Contract statuses”The status field progresses through the contract lifecycle:
| Status | Meaning |
|---|---|
created | Contract exists but hasn’t been sent. |
sending | A send is in progress (the transient state returned by 202). |
sent | Sent to the consumer for signature. |
partiallySigned | Some, but not all, required signers have signed. |
signed | Fully signed by the consumer. |
approved | Approved by LightReach. |
rejected | Rejected. |
voided | Voided. |
disclosureSent / disclosureSigned | State-specific disclosure (e.g. Illinois Shines) sent / signed. |
Errors
Section titled “Errors”| Status | When |
|---|---|
400 | A precondition failed — no valid DocuSign documents to send, monthly payment cap exceeded, missing annual consumption for an enforced solar jurisdiction, or invalid organization account progress. |
403 | The token can’t access the account. |
404 | (Signing link) no current sendable contract exists for the account. |
409 | A send for this account is already in progress. |
Standard Finance API rate limits apply.
End-to-end example
Section titled “End-to-end example”Send the contract, then wait for the sent outcome via the progress stream, opening the stream before the send to avoid missing the terminal event.
import fetch from 'node-fetch';import EventSource from 'eventsource';
async function sendContract(envBaseUrl, accessToken, accountId, externalReference) { const auth = { Authorization: `Bearer ${accessToken}` };
// 1. Open the progress stream first so we don't miss the terminal event. const sse = new EventSource( `${envBaseUrl}/api/v2/accounts/${accountId}/contracts/current/send/progress`, { headers: auth }, ); const done = new Promise((resolve, reject) => { sse.onmessage = (e) => { const event = JSON.parse(e.data); if (event.status === 'sent') { sse.close(); resolve(event); } else if (event.status === 'failed') { sse.close(); reject(new Error(event.error)); } }; sse.onerror = () => { sse.close(); reject(new Error('SSE connection error')); }; });
// 2. Kick off the async send. const res = await fetch(`${envBaseUrl}/api/v2/accounts/${accountId}/contracts/current/send`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ externalReference }), }); if (res.status === 409) throw new Error('A send is already in progress for this account'); if (!res.ok) throw new Error(`Send failed: ${res.status}`); const contract = await res.json(); if (res.status === 200) { sse.close(); return contract; } // already complete, nothing to await
// 3. Wait for completion. await done; return contract;}For exact request/response schemas, see the Finance API Reference.