Webhooks
Webhooks push events to you — when something changes in your company's data,
we send an HTTP POST to a URL you control. That means no polling: you react the
moment an offer is accepted or a project is created.
When to use them
- Kick off fulfilment the instant a customer accepts an offer
(
offer.accepted) — create an ERP order, generate an invoice, notify your team. - Mirror data into a CRM and keep it fresh (
project.created,project.updated). - Drive a pipeline board from offer state changes (
offer.state_changed). - Automate with n8n / Make / Zapier — point a webhook at your automation
tool and branch on
type.
If you only need the current state occasionally, a normal GET is simpler. Reach
for webhooks when you need to act as changes happen.
Set up an endpoint
- In the planner, go to Settings → Webhooks.
- Add your HTTPS URL (must be
https://) and pick the events to receive. - Copy the signing secret (shown once) — you'll use it to verify deliveries.
You can register several endpoints, each subscribed to a different set of events.
Events
| Event | Fires when | Typical use |
|---|---|---|
customer.created | A customer is added | Create the matching CRM contact |
customer.updated | A customer's details change | Keep a mirror in sync |
customer.deleted | A customer is removed | Tidy up your copy |
project.created | A project is created | Open a matching CRM record |
project.updated | A project's fields change | Keep a mirror in sync |
project.deleted | A project is removed | Close the record; its offers go too |
offer.created | An offer is created (as a DRAFT) | Track quoting activity |
offer.updated | An offer is edited | Refresh your copy |
offer.published | An offer becomes visible to the customer | Start a follow-up sequence |
offer.accepted | A customer accepts an offer | Start fulfilment / invoicing |
offer.state_changed | An offer changes state | Update a pipeline board |
offer.deleted | An offer is removed | Drop it from your pipeline |
material.created | A product is added to the catalog | Mirror your catalog |
material.updated | A product is renamed, archived or re-assigned | Keep the mirror fresh |
material.deleted | A product is removed from the catalog | Drop it from the mirror |
A few events are deliberately quiet, so a single edit does not arrive as a burst:
- Counters do not count. A customer gaining an offer does not raise
customer.updated— only fields the API exposes do. - Prices do not count. An offer whose total is recalculated from its line
items raises nothing, and giving a product a new purchase price does not raise
material.updated. - State changes are reported once, as state changes. Editing an offer raises
offer.updated; moving it through the pipeline raisesoffer.state_changed(plusoffer.publishedoroffer.accepted), never both for the same write. - Cascades are reported at the top. Deleting a project raises
project.deleted, not oneoffer.deletedper offer that went with it.
Payload
Every delivery shares one envelope; the data object is event-specific:
{
"id": "a1b2c3d4-e5f6-4890-a1b2-c3d4e5f60789",
"type": "offer.accepted",
"createdAt": "2026-05-20T12:34:56.789Z",
"data": {
"id": "812",
"projectId": "41",
"state": "ACCEPTED",
"acceptedAt": "2026-05-20T12:34:56.789Z",
"createdAt": "2026-05-01T09:00:00Z"
}
}
Envelope fields:
id— unique event/delivery id. Use it as an idempotency key — the same event may arrive more than once.type— the event name; branch on it.createdAt— when the event was sent (ISO 8601).data— a compact set of the affected record's key fields (see below).
Payloads are deliberately small notifications, not full records. data.id is
the affected resource's id — call the REST API (GET /v1/offers/{id},
GET /v1/projects/{id}, …) to fetch the complete object.
data per event
customer.created, customer.updated, customer.deleted
| field | type | description |
|---|---|---|
id | string | the customer id |
firstName | string | given name |
lastName | string | family name |
email | string | contact email |
createdAt | string | customer creation time (ISO 8601) |
project.created, project.updated, project.deleted
| field | type | description |
|---|---|---|
id | string | the project id |
name | string | project name |
customerId | string | the owning customer |
createdAt | string | project creation time (ISO 8601) |
offer.created, offer.updated, offer.deleted
| field | type | description |
|---|---|---|
id | string | the offer id |
projectId | string | the offer's project |
name | string | offer name |
state | string | offer state at the time of the event |
isIndicationPrice | boolean | indication rather than detailed offer |
createdAt | string | offer creation time |
offer.published
| field | type | description |
|---|---|---|
id | string | the offer id |
projectId | string | the offer's project |
state | string | always PUBLISHED |
validTo | string | when the published offer expires |
createdAt | string | offer creation time |
offer.accepted
| field | type | description |
|---|---|---|
id | string | the offer id |
projectId | string | the offer's project |
state | string | always ACCEPTED |
acceptedAt | string | when it was accepted |
createdAt | string | offer creation time |
offer.state_changed
| field | type | description |
|---|---|---|
id | string | the offer id |
projectId | string | the offer's project |
previousState | string | state before the change |
state | string | the new state |
createdAt | string | offer creation time |
material.created, material.updated, material.deleted
| field | type | description |
|---|---|---|
id | string | the material id |
materialType | string | which catalog: pv_module, battery, inverter, wallbox, equipment, misc, subconstruction, emergency_power |
name | string | product name |
archived | boolean | archived products stay on existing offers but are hidden from new ones |
manufacturerId | string | the manufacturer |
Verifying deliveries
Deliveries are signed per the Standard Webhooks spec. Three headers travel with each request:
| Header | Description |
|---|---|
webhook-id | Same as the body id — idempotency key |
webhook-timestamp | Unix seconds at sign time — reject if skewed more than 5 minutes |
webhook-signature | v1,<base64/hex> — HMAC-SHA256 over ${id}.${timestamp}.${rawBody} |
Always verify the signature before trusting a payload, using your endpoint's signing secret and the raw request body:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(
secret: string,
headers: { id: string; timestamp: string; signature: string },
rawBody: string,
): boolean {
// Reject stale deliveries (replay protection).
const skew = Math.abs(Date.now() / 1000 - Number(headers.timestamp));
if (Number.isNaN(skew) || skew > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${headers.id}.${headers.timestamp}.${rawBody}`)
.digest("hex");
// The header may carry several space-separated `v1,<sig>` values.
return headers.signature.split(" ").some((part) => {
const sig = part.startsWith("v1,") ? part.slice(3) : part;
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
});
}
Compute the signature over the exact bytes you received. If your framework re-serializes JSON (reordering keys, changing whitespace) the HMAC won't match. Read the raw body before any JSON parsing.
Responding
- Return any 2xx status to acknowledge. Anything else — or a response slower than 10 seconds — counts as a failed delivery.
- Respond fast, work async. Enqueue the event and return
200immediately; don't do slow processing inline. - Be idempotent. De-duplicate on
webhook-id; retries and rare duplicates are normal. - Don't assume order. Events can arrive out of order; treat each as "this is
the latest state of X" and re-fetch via
GETif you need certainty.
Retries & failures
- Failed deliveries retry with exponential backoff capped at 1 hour, up to 8 attempts.
- After 20 consecutive failures the endpoint is auto-disabled — fix your receiver, then re-enable the endpoint in the planner.
- Paused/disabled endpoints park their queue. Events keep queueing while an endpoint is disabled and are all delivered once it is re-enabled — pausing loses nothing, it just delays.
- Every delivery attempt is visible under Settings → Webhooks, where you can also re-deliver a single event.
Endpoint URLs
Endpoints must be public https:// URLs. Addresses that point at private or
internal infrastructure (localhost, RFC 1918 ranges, link-local, *.internal
and similar) are rejected on creation.
Testing
Point an endpoint at a request bin (e.g. webhook.site) to inspect real payloads and headers, then re-deliver from the planner while you build your verification and handler.