Project Events (v3)
The project events API lets you report the steps that happen on a LightReach account from sign-up through activation, such as a site survey, a permit submission, the install or an inspection, and read them back. It uses the same access token as the rest of the Finance API. This guide assumes you already have an access token and an account ID. If you don’t, start with the Getting Started guide.
Base path and authentication
Section titled “Base path and authentication”All endpoints are rooted at:
${envBaseUrl}/api/v3/accounts/{accountId}/project-eventsenvBaseUrl 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.
Event names and mapping
Section titled “Event names and mapping”Palmetto recognizes a set of standard events, each with a name such as INSTALL_COMPLETED, a friendly name and a category. When you post an event, it is matched to a standard event, and the result is returned as mappedEvent, friendlyName and category. An event that doesn’t match is still stored exactly as you sent it. It is returned with a mappedEvent of UNMAPPED_EVENT, and a friendlyName and category of null.
If your system uses its own wording for events, such as Roofing Permit Submitted, contact developer support and we’ll set up a mapping from your wording to the standard events for your organization.
Standard event names follow a convention that tells you what the event means:
| Suffix | Meaning | Example |
|---|---|---|
_FORECAST |
An estimate. Nobody is booked. | INSTALL_FORECAST with eventDateTime 2026-11-10 and eventEndDateTime 2026-11-20 for “mid-November” |
_SCHEDULED |
A committed, booked date or window. | INSTALL_SCHEDULED with eventDateTime 2026-11-14T08:00 and eventEndDateTime 2026-11-14T10:00 |
| none | Something that happened. | ROOFING_PERMIT_SUBMITTED, INSTALL_COMPLETED |
For forecasts and schedules, eventDateTime is the date being forecast or scheduled, not the date you posted it.
See Launch events for the full list of standard event names.
Posting an event
Section titled “Posting an event”POST /api/v3/accounts/{accountId}/project-events
Records an event for the account.
import fetch from 'node-fetch';
const response = await fetch(`${envBaseUrl}/api/v3/accounts/${accountId}/project-events`, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ event: 'ROOFING_PERMIT_SUBMITTED', eventDateTime: '2026-10-23', eventEndDateTime: null, data: { description: 'Permit was submitted to AHJ' }, }),});
const result = await response.json();Request body
| Field | Type | Required | Notes |
|---|---|---|---|
event |
string | yes | The event name. Preserved exactly as you send it. See Event names and mapping. |
eventDateTime |
string | yes | When the event happened, or when it starts. An ISO 8601 date or date and time. With no offset or timezone it is read as UTC. |
eventEndDateTime |
string | null | no | The end of a window, for events that span a period. null or omitted means a single point in time. If set, it must not be before eventDateTime. |
data |
object | no | Additional details about the event. See data properties. |
accountId |
string | no | If present, it must match the {accountId} in the path, otherwise the request fails with 422. |
Fields other than those above are ignored.
data properties
Section titled “data properties”| Property | Type | Notes |
|---|---|---|
description |
string, up to 500 characters | Free-text explanation of the event, for example “customer rescheduled”. |
Other keys in data are accepted and stored with the event, but they aren’t used for anything.
Responses
| Status | Meaning |
|---|---|
201 |
The event was stored. |
200 |
An exact duplicate of the current event. Nothing new is stored, and the existing event’s IDs are returned. |
422 |
The body accountId doesn’t match the path, or the request is invalid. The response lists the field errors. |
Both success responses have the same body:
{ eventId: '6540f1d3a4b5c6d7e8f9a0b1', // the event that was stored, or the existing duplicate projectId: '6540f1c2a3b4d5e6f7a8b9c0', // the account's project, which holds all of its events}Use eventId to get the event back. If the new event replaced an earlier one, the earlier event’s replacedBy and the new event’s replacesEvent show the link.
See Errors for the other statuses.
Replacements and retries
Section titled “Replacements and retries”Every event you send is kept. When you send an event that has already been received for the account, and its content differs, the new event replaces the earlier one. Nothing is deleted or overwritten. The two events are linked through replacesEvent and replacedBy, and the newer one is the current event.
- What counts as the same event. The same standard event for the account. An event that didn’t match a standard event is never replaced, so each one with different content is kept as its own event. Different kinds of the same thing, such as
ROOFING_PERMIT_SUBMITTEDandSOLAR_PERMIT_SUBMITTED, are different events and never replace each other. - Rescheduling. Post a new
INSTALL_SCHEDULEDevent with the new date. The earlier one stays in the history and is marked as replaced. - The newest post wins, not the newest
eventDateTime. A late post carrying an older date still replaces the earlier event.
Retrying is safe. If you send the same event, eventDateTime, eventEndDateTime and data as the current event, you get a 200 with the existing event’s IDs and nothing new is stored. This is checked against the current event only, so changing a value and then changing it back is recorded as a new replacement.
Listing events
Section titled “Listing events”GET /api/v3/accounts/{accountId}/project-events
Returns the account’s events, newest first by eventDateTime.
| Query param | Type | Notes |
|---|---|---|
from |
string | Only include events whose eventDateTime is at or after this moment. An ISO 8601 date and time, for example 2026-10-21T00:00:00Z. |
to |
string | Only include events whose eventDateTime is at or before this moment. Same format as from. |
mappedEvent |
string | Only include events that matched this standard event, for example INSTALL_COMPLETED. Use UNMAPPED_EVENT to find events that didn’t match one. |
latestOnly |
boolean | true to only include current events, leaving out ones that have been replaced. Off by default. |
import fetch from 'node-fetch';
const params = new URLSearchParams({ from: '2026-01-01T00:00:00Z', latestOnly: 'true' });const res = await fetch( `${envBaseUrl}/api/v3/accounts/${accountId}/project-events?${params}`, { headers: { Authorization: `Bearer ${accessToken}` } },);const { data: events } = await res.json();The response is an object with the events in a data array. Each item has the event shape below.
{ data: [ { id: '6540f1d3a4b5c6d7e8f9a0c4', projectId: '6540f1c2a3b4d5e6f7a8b9c0', event: 'INSTALL_SCHEDULED', mappedEvent: 'INSTALL_SCHEDULED', friendlyName: 'Install scheduled', category: 'Install', eventDateTime: '2026-11-18T08:00:00.000Z', eventEndDateTime: '2026-11-18T10:00:00.000Z', dateOnly: false, data: { description: 'Customer rescheduled' }, replacesEvent: '6540f1d3a4b5c6d7e8f9a0b8', // replaces the earlier INSTALL_SCHEDULED below replacedBy: null, // null, so this is the current event meta: { createdAt: '2026-11-10T15:20:41.000Z' }, }, { id: '6540f1d3a4b5c6d7e8f9a0b8', projectId: '6540f1c2a3b4d5e6f7a8b9c0', event: 'INSTALL_SCHEDULED', mappedEvent: 'INSTALL_SCHEDULED', friendlyName: 'Install scheduled', category: 'Install', eventDateTime: '2026-11-14T08:00:00.000Z', eventEndDateTime: '2026-11-14T10:00:00.000Z', dateOnly: false, data: {}, replacesEvent: null, replacedBy: '6540f1d3a4b5c6d7e8f9a0c4', meta: { createdAt: '2026-11-02T14:05:09.000Z' }, }, { id: '6540f1d3a4b5c6d7e8f9a0b1', projectId: '6540f1c2a3b4d5e6f7a8b9c0', event: 'ROOFING_PERMIT_SUBMITTED', mappedEvent: 'ROOFING_PERMIT_SUBMITTED', friendlyName: 'Roofing permit submitted', category: 'Approvals', eventDateTime: '2026-10-23T00:00:00.000Z', eventEndDateTime: null, dateOnly: true, data: { description: 'Permit was submitted to AHJ' }, replacesEvent: null, replacedBy: null, meta: { createdAt: '2026-10-23T13:02:11.000Z' }, }, ],}Every matching event is returned, replaced ones included unless you set latestOnly. There is no limit and no paging. The data array is empty if no events have been posted for the account yet.
Getting a single event
Section titled “Getting a single event”GET /api/v3/accounts/{accountId}/project-events/{eventId}
Returns the event if it belongs to the account. Responds 404 if it isn’t found.
import fetch from 'node-fetch';
const res = await fetch( `${envBaseUrl}/api/v3/accounts/${accountId}/project-events/${eventId}`, { headers: { Authorization: `Bearer ${accessToken}` } },);const event = await res.json();The response is the event itself, in the event shape below, not wrapped in another object.
Event shape
Section titled “Event shape”Both read endpoints return events with these fields:
| Field | Description |
|---|---|
id |
The event identifier. |
projectId |
The account’s project, which holds all of its events. |
event |
The event name exactly as you sent it. |
mappedEvent |
The standard event this event matched, or UNMAPPED_EVENT if it didn’t match one. |
friendlyName |
A display name for the standard event. null when the event is unmapped. |
category |
The standard event’s category: Design & survey, Approvals, Pre-install, Install, Inspection or Activation. null when the event is unmapped. |
eventDateTime |
When the event happened, or when it starts, as an ISO 8601 UTC date and time. |
eventEndDateTime |
When the event ends, or null for a single point in time. |
dateOnly |
true if you sent eventDateTime as a date with no time. The time portion of eventDateTime is then midnight UTC and shouldn’t be relied on. |
data |
The additional details sent with the event. |
meta.createdAt |
When the event was recorded. |
replacesEvent |
The ID of the event this one replaces, or null. |
replacedBy |
The ID of the event that replaced this one, or null while it is current. |
Launch events
Section titled “Launch events”These are the standard events Palmetto recognizes at launch, grouped by category. Send these names as the event and they match automatically — see Event names and mapping. The friendlyName is returned on reads for events that match.
Design & survey
Section titled “Design & survey”| Event | Friendly name |
|---|---|
SITE_SURVEY_SCHEDULED |
Site survey scheduled |
SITE_SURVEY_COMPLETED |
Site survey completed |
DESIGN_FINAL_PLANSET_ISSUED |
Final design / plan set issued |
Approvals
Section titled “Approvals”Each kind of permit has the same four events, and each kind is tracked separately. The friendly name is the permit kind followed by the state, for example “Roofing permit submitted”.
| Permit kind | Submitted | Approved | Rejected | Issued |
|---|---|---|---|---|
| Roofing | ROOFING_PERMIT_SUBMITTED |
ROOFING_PERMIT_APPROVED |
ROOFING_PERMIT_REJECTED |
ROOFING_PERMIT_ISSUED |
| Solar | SOLAR_PERMIT_SUBMITTED |
SOLAR_PERMIT_APPROVED |
SOLAR_PERMIT_REJECTED |
SOLAR_PERMIT_ISSUED |
| Electrical | ELECTRICAL_PERMIT_SUBMITTED |
ELECTRICAL_PERMIT_APPROVED |
ELECTRICAL_PERMIT_REJECTED |
ELECTRICAL_PERMIT_ISSUED |
| Plumbing | PLUMBING_PERMIT_SUBMITTED |
PLUMBING_PERMIT_APPROVED |
PLUMBING_PERMIT_REJECTED |
PLUMBING_PERMIT_ISSUED |
| Tree | TREE_PERMIT_SUBMITTED |
TREE_PERMIT_APPROVED |
TREE_PERMIT_REJECTED |
TREE_PERMIT_ISSUED |
| AHJ | AHJ_PERMIT_SUBMITTED |
AHJ_PERMIT_APPROVED |
AHJ_PERMIT_REJECTED |
AHJ_PERMIT_ISSUED |
| HOA | HOA_PERMIT_SUBMITTED |
HOA_PERMIT_APPROVED |
HOA_PERMIT_REJECTED |
HOA_PERMIT_ISSUED |
| Event | Friendly name |
|---|---|
INTERCONNECTION_SUBMITTED |
Utility Interconnection Application Submitted |
INTERCONNECTION_APPROVED |
Utility Interconnection Application Approved |
INTERCONNECTION_REJECTED |
Utility Interconnection Application Rejected |
Pre-install
Section titled “Pre-install”Each kind of pre-install work has a forecast, a scheduled and a completed event. See Event names and mapping for what the suffixes mean.
| Work | Forecast | Scheduled | Completed |
|---|---|---|---|
| Electrical upgrade | ELECTRICAL_WORK_FORECAST |
ELECTRICAL_WORK_SCHEDULED |
ELECTRICAL_WORK_COMPLETED |
| Roofing work | ROOFING_WORK_FORECAST |
ROOFING_WORK_SCHEDULED |
ROOFING_WORK_COMPLETED |
| Trenching | TRENCHING_WORK_FORECAST |
TRENCHING_WORK_SCHEDULED |
TRENCHING_WORK_COMPLETED |
| Structural reinforcement | STRUCTURAL_WORK_FORECAST |
STRUCTURAL_WORK_SCHEDULED |
STRUCTURAL_WORK_COMPLETED |
| Tree removal | TREE_REMOVAL_WORK_FORECAST |
TREE_REMOVAL_WORK_SCHEDULED |
TREE_REMOVAL_WORK_COMPLETED |
Install
Section titled “Install”| Event | Friendly name |
|---|---|
INSTALL_FORECAST |
Install forecast |
INSTALL_SCHEDULED |
Install scheduled |
INSTALL_COMPLETED |
Install completed |
Inspection
Section titled “Inspection”| Event | Friendly name |
|---|---|
INSPECTION_FORECAST |
Inspection forecast |
INSPECTION_SCHEDULED |
Inspection scheduled |
INSPECTION_COMPLETED |
Inspection completed |
INSPECTION_PASSED |
Inspection passed |
INSPECTION_FAILED |
Inspection failed |
Activation
Section titled “Activation”| Event | Friendly name |
|---|---|
PTO_APPLICATION_SUBMITTED |
PTO application submitted |
PTO_GRANTED |
Permission to operate granted |
Errors
Section titled “Errors”| Status | When |
|---|---|
401 |
The token is missing or invalid. |
403 |
The token can’t access the account, or isn’t associated with an organization. |
404 |
The account or event wasn’t found. |
422 |
The body accountId doesn’t match the path, or the request is invalid. |
503 |
The service is temporarily unavailable. Retry shortly. |
Standard Finance API rate limits apply.
For exact request/response schemas, see the Finance API Reference.