Skip to content

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.

All endpoints are rooted at:

${envBaseUrl}/api/v3/accounts/{accountId}/project-events

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.

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.

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.

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.

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_SUBMITTED and SOLAR_PERMIT_SUBMITTED, are different events and never replace each other.
  • Rescheduling. Post a new INSTALL_SCHEDULED event 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.

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.

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.

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.

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.

Event Friendly name
SITE_SURVEY_SCHEDULED Site survey scheduled
SITE_SURVEY_COMPLETED Site survey completed
DESIGN_FINAL_PLANSET_ISSUED Final design / plan set issued

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

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
Event Friendly name
INSTALL_FORECAST Install forecast
INSTALL_SCHEDULED Install scheduled
INSTALL_COMPLETED Install completed
Event Friendly name
INSPECTION_FORECAST Inspection forecast
INSPECTION_SCHEDULED Inspection scheduled
INSPECTION_COMPLETED Inspection completed
INSPECTION_PASSED Inspection passed
INSPECTION_FAILED Inspection failed
Event Friendly name
PTO_APPLICATION_SUBMITTED PTO application submitted
PTO_GRANTED Permission to operate granted
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.