Skip to content

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.

v1 (/api/accounts/:accountId/contracts/...)v2 (/api/v2/accounts/:accountId/contracts/...)
SendSynchronous — the request blocks until the envelope is dispatched, then returns the contract with status: sentAsynchronous — returns 202 immediately with status: sending; completion tracked via SSE or webhooks
ConcurrencyNo guard — parallel sends could raceA per-account lock rejects a second concurrent send with 409
Signing linkPOST .../contracts/current/signing-link with a returnUrl bodyGET .../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.

All endpoints are rooted at:

${envBaseUrl}/api/v2/accounts/{accountId}/contracts

envBaseUrl 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.

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)

FieldTypeNotes
externalReferencestringYour 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

ParamTypeNotes
forcebooleanForces 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.

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.
StatusMeaning
202 AcceptedThe send was accepted. The returned contract has status: sending. Track completion via the progress stream or webhooks. This is the normal path.
200 OKThe 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 RequestA precondition failed (no valid documents to send, payment cap exceeded, missing annual consumption, etc.).
409 ConflictA send for this account is already in progress. Wait for it to finish (watch the progress stream or webhooks) before retrying.

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: [
{ name: 'Jane Doe', email: '[email protected]', type: 'signer', status: 'created' },
],
},
],
// `url` is present only on responses that carry a signing link; see below.
}

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.

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'));
};
});

If you already consume webhooks, you don’t need the stream at all — the contract lifecycle is fully covered by events:

EventWhen
contractSendingThe async send was accepted and is being dispatched.
contractSentThe contract was sent to the consumer for signature. This is the completion signal for a send.
contractSignedThe consumer signed the contract.
contractApprovedLightReach approved the signed contract.
contractVoided / contractReinstatedThe 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.

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 paramTypeRequiredNotes
returnUrlstringyesWhere 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).

The status field progresses through the contract lifecycle:

StatusMeaning
createdContract exists but hasn’t been sent.
sendingA send is in progress (the transient state returned by 202).
sentSent to the consumer for signature.
partiallySignedSome, but not all, required signers have signed.
signedFully signed by the consumer.
approvedApproved by LightReach.
rejectedRejected.
voidedVoided.
disclosureSent / disclosureSignedState-specific disclosure (e.g. Illinois Shines) sent / signed.
StatusWhen
400A 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.
403The token can’t access the account.
404(Signing link) no current sendable contract exists for the account.
409A send for this account is already in progress.

Standard Finance API rate limits apply.

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.