# Guide to Palmetto Finance Webhooks: Features and Best Practices

Palmetto Finance webhooks keep your installer or sales platform in step with LightReach, Palmetto's residential solar and energy financing product. Instead of polling for every account, credit application and contract, you subscribe once and Palmetto posts each change to your endpoint, so you can update your CRM, move a project to the next stage or prompt a homeowner the moment something happens.

This guide covers how registrations and subscriptions work, the events and payloads Palmetto sends, how to authenticate requests with the server API key, how retries and auto-disable work, and the practices that keep a Palmetto receiver reliable.

## What are Palmetto Finance webhooks?

Palmetto Finance webhooks are set up in two steps:

1. A **registration** belongs to your organisation and holds the host URL and the authentication method. Events are scoped to your organisation, unless you set `includeVisibleOrgEvents` to `true`, in which case you also receive events for organisations yours has been granted access to.
2. A **subscription** on that registration adds an endpoint path, an HTTP method and the event types to send. Palmetto delivers to the registration's `hostUrl` plus the subscription's `endpointUrl`.

Palmetto doesn't sign webhook requests. It offers three static ways to authenticate them:

* **Server API key**, the default. Palmetto generates a key and returns it once, when you create the registration, then sends it in a header with every event.
* **Client headers.** You supply a client ID and secret, and Palmetto sends them as two headers, `clientId` and `clientSecret`.
* **Client basic authorization header.** You supply a client ID and secret, and Palmetto sends `Authorization: Basic` with the base64 of `{clientId}:{clientSecret}`.

The docs disagree on the API key header's name. The registration section says calls are made "with an `api_key` header", and the Authentication section says "an `apiKey` header". Check a real request before you hard-code either.

## Palmetto Finance webhook features

| Feature | Details |
| --- | --- |
| Configuration | Registrations and subscriptions through the API, or **Developer Settings** in your organisation settings |
| Delivery URL | Registration `hostUrl` (domain only) plus subscription `endpointUrl` (path) |
| Method | Set per subscription with `httpMethod`. Palmetto's example uses `POST` |
| Event field | `event` in the body |
| Subscriptions | Individual event types, or `allEvents`, `allContractEvents`, `allStipulationEvents` and `allConsumerTaskEvents` |
| Authentication | Server API key (default), client headers, or client Basic. No signature |
| Timeout | 30 seconds per attempt |
| Retries | `5xx`, `429` and network errors, up to 9 retries with exponential backoff from 3 minutes |
| Not retried | Other `4xx` (including `401`), invalid URLs, and codes you list in `nonretryableErrorCodes` |
| Auto-disable | After 10 failures. Later events are logged as skipped and can be replayed after re-enabling |
| Source IPs | Not published |

## Common events

| Area | `event` | Fires when |
| --- | --- | --- |
| Accounts | `accountCreated`, `accountUpdated`, `sendInvite` | An account is created or updated, or a consumer application invite is sent |
| Applications | `applicationStatus` | A credit application's status changes |
| Contracts | `contractSending`, `contractSent`, `contractSigned`, `contractApproved`, `contractVoided`, `contractReinstated` | A contract starts sending, is sent, signed, approved, voided or reinstated |
| Quotes | `quoteCreated`, `quoteVoided`, `activeQuoteExceedsMonthlyPaymentCaps` | A quote is created or voided, or an account's quote exceeds the monthly payment cap |
| Documents | `documentUploaded`, `documentStatus` | A document is uploaded, or its status changes |
| Milestones | `milestoneAchieved`, `milestoneStatusChanged`, `milestonePackage` | A milestone is reached, a milestone status changes, or a milestone package event occurs |
| Stipulations and requirements | `stipulationAdded`, `stipulationCleared`, `allStipulationsCleared`, `requirementStatusChanged`, `requirementCompleted` | A stipulation is added or cleared, all are cleared, or an account requirement changes or completes |
| Consumer | `termsAndConditionsAccepted`, `illinoisShineDisclosureSent`, `illinoisShineDisclosureSigned` | Terms are accepted, or the Illinois Shines disclosure is sent or signed |

Bodies are flat JSON objects: `event` plus the fields for that event. Here is the documented shape of `contractSigned`, with Palmetto's placeholder values:

```json
{
  "event": "contractSigned",
  "accountId": "palmetto-finance-account-id",
  "accountReference": "account-external-reference",
  "contractReference": "externalReference-provided-in-contract-send",
  "quoteReference": "quote-external-reference"
}

```

`accountReference` and the other `*Reference` fields are your own external references, which makes it easy to match an event to a record in your system. Larger events, such as `applicationStatus`, include applicant details, so treat bodies as personal data.

## Setting up Palmetto Finance webhooks

Create a registration with your domain as `hostUrl`:

```javascript
const response = await fetch(`${envBaseUrl}/api/webhooks/registrations`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' /* plus your API auth */ },
  body: JSON.stringify({
    name: 'Production receiver',
    hostUrl: 'https://example.com',
    includeVisibleOrgEvents: false,
  }),
});
const { webhookRegistrationId, apiKey } = await response.json();

```

Store `apiKey` straight away, for example as `PALMETTO_WEBHOOK_API_KEY`. Palmetto returns it only once. Then subscribe:

```javascript
await fetch(`${envBaseUrl}/api/webhooks/registrations/${webhookRegistrationId}/subscriptions`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' /* plus your API auth */ },
  body: JSON.stringify({
    endpointUrl: '/webhooks/palmetto',
    httpMethod: 'POST',
    eventType: ['allEvents'],
  }),
});

```

Palmetto recommends `allEvents` as a starting point. You can also create a registration in the Palmetto Finance UI from the **Developer Settings** tab of your organisation settings.

**With Hookdeck.** Use the default server API key authentication. Palmetto shows the key only once, when you register the webhook, so copy it then. Create a Hookdeck source with the **Palmetto Finance** type, enter `apiKey` in **API Key Header** unless your requests show `api_key`, and paste the key into **API Key**. Hookdeck looks the header name up case-insensitively, but `apiKey` and `api_key` are different names, so if verification fails, check the stored request for the header that actually arrived. Set the registration's `hostUrl` to the source URL's origin and the subscription's `endpointUrl` to its path. Hookdeck pre-selects `POST` but doesn't manage the method. Palmetto's docs only show `POST` for `httpMethod`, so if you set a different method on a subscription, allow it on the source as well.

## Securing Palmetto Finance webhooks

Accept the API key under either documented header name, compare it in constant time, and only then parse the body.

```javascript
const crypto = require('crypto');
const express = require('express');

const API_KEY = process.env.PALMETTO_WEBHOOK_API_KEY;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

app.post('/webhooks/palmetto', express.raw({ type: '*/*' }), (req, res) => {
  // The docs name the header both apiKey and api_key; Node lowercases header names.
  const received = req.get('apikey') ?? req.get('api_key');
  if (!API_KEY || typeof received !== 'string' || !safeEqual(received, API_KEY)) {
    return res.sendStatus(401);
  }

  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // Route on event.event, queue the work, then respond within 30 seconds.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

API_KEY = os.environ.get("PALMETTO_WEBHOOK_API_KEY", "")

def verify_palmetto_request(headers) -> bool:
    if not API_KEY:
        return False  # fail closed when unconfigured
    # Header maps in Starlette and Flask are case-insensitive.
    received = headers.get("apiKey") or headers.get("api_key")
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), API_KEY.encode("utf-8"))

```

Once you've seen which header Palmetto sends, you can drop the other lookup.

A static key authenticates the sender, not the body. It can't detect a modified payload, and with no signed timestamp a captured request can be replayed and still pass. Use HTTPS for `hostUrl` and keep the key out of logs.

> **Make Palmetto Finance webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the API key on every event, answers Palmetto straight away, and delivers each event to your service with retries you control.

## Palmetto Finance webhook limitations and pain points

### A `401` is never retried and counts towards auto-disable

**The Problem:** A misconfigured key loses events and, before long, switches the subscription off.

**Why It Happens:** Palmetto treats `4xx` responses other than `429` as non-retryable, so an event your receiver rejects with `401` is marked failed at once. A subscription that accumulates 10 failures is disabled, and later events are logged as skipped.

**Workarounds:**

* Test the key against a real request before you go live, and alert on any `401` from your receiver.
* After fixing a problem, re-enable the subscription in the UI or API and replay the skipped events.

**How Hookdeck Can Help:** Hookdeck's Palmetto Finance source compares the header you name with the exact value you enter. Hookdeck answers Palmetto with a `200` either way, then records a request without the right key as failed verification, so it never reaches your destination. A wrong key shows up in Hookdeck's request log instead of counting against Palmetto's failure limit.

### A key you only see once

**The Problem:** You lose the API key and have nothing to compare requests against.

**Why It Happens:** The server API key "is returned from the registration endpoint, and only once".

**Workarounds:**

* Write the key to your secrets manager in the same code that creates the registration.
* The docs describe no way to retrieve the key again, so plan on creating a new registration if it's lost.

**How Hookdeck Can Help:** Pointing the registration at a Hookdeck source means the key needs updating in one place when you replace it.

### Auto-disable during outages

**The Problem:** A long outage on your side can leave the subscription disabled, so new events stop as well as the failed ones.

**Why It Happens:** Palmetto retries `5xx`, `429` and network errors up to 9 times with exponential backoff starting at 3 minutes, and a subscription that accumulates 10 failures is disabled.

**Workarounds:**

* Watch the subscription's status and re-enable it, then replay skipped events.
* Answer with a `2xx` quickly and process in the background, so slow work doesn't time out at 30 seconds.

**How Hookdeck Can Help:** Hookdeck accepts each event as it arrives, so Palmetto sees successful deliveries even when your service is down. Hookdeck then delivers to your service with [retries](/docs/retries) on your schedule and lets you replay any stored event.

## Best practices

### Start with `allEvents`, route on `event`

Palmetto recommends `allEvents`. Switch on `event` in your handler and acknowledge anything you don't recognise with a `200`, so new event types never count as failures.

### Match on your own references

Events echo your external references, such as `accountReference`, `contractReference` and `quoteReference`. Set them on the records you create so you can match an event to your own data without a lookup.

### Re-read state for important actions

Webhook bodies are summaries. Before funding or contract decisions, fetch the account or contract from the API.

### Make handlers idempotent

Retries can deliver an event more than once, and no event ID is documented. Build a key from `event`, the ID fields and the status or milestone the event reports. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast

Answer within the 30-second timeout, ideally in well under a second, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Palmetto Finance delivers flat JSON events for accounts, applications, contracts, quotes, documents and milestones to the URL formed by your registration and subscription, authenticated by a static API key rather than a signature. Store the key the moment it's issued, check it under the header your requests actually use, route on `event`, and avoid `4xx` responses for anything you want retried, because they aren't retried and ten failures disable the subscription.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the key, answers Palmetto at once, and keeps every event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Palmetto Finance webhooks reliably in minutes.