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 |
booking.created | An appointment becomes a real booking | Put it in a team calendar |
booking.rescheduled | A confirmed appointment moves | Move your calendar entry |
booking.cancelled | An appointment is cancelled | Free the slot, follow up |
booking.completed | An appointment took place | Trigger the next step |
booking.no_show | The customer did not turn up | Re-engage the lead |
booking.payment_paid | A paid appointment is settled | Reconcile in your books |
booking.payment_refunded | A paid appointment is refunded | Reverse the entry |
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. - Unpaid holds are not bookings. A paid appointment holds its slot while the
customer is in checkout. Nothing is sent until the payment clears, so a
checkout that is abandoned or expires never produces an event you would have
to retract.
booking.createdis the moment it became real, whether that is on booking (free and manually entered appointments) or on payment.
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 |
booking.created, booking.completed, booking.no_show
| field | type | description |
|---|---|---|
id | string | the appointment id |
source | string | where it came from: BUILDER (your configurator), BOOKING_PAGE (your shared booking link) or PLANNER (entered by hand) |
typeSlug, typeName | string | the kind of appointment booked |
startsAt, endsAt | string | when it runs |
status | string | CONFIRMED, COMPLETED, NO_SHOW, … |
paymentStatus | string | FREE, UNPAID, PAID or REFUNDED |
priceCents, currency | number, string | what it costs |
customer | object | name, email, phone |
offerRequestId | string | the offer request a BUILDER booking produced — fetch it via GET /v1/offer-requests/{id} |
createdAt | string | when the booking was made |
booking.rescheduled — the fields above plus:
| field | type | description |
|---|---|---|
previousStartsAt | string | where the appointment was before it moved |
booking.cancelled — the fields above plus:
| field | type | description |
|---|---|---|
cancellationReason | string | what was given as the reason |
cancelledAt | string | when it was cancelled |
refunded | boolean | whether money went back; the amount arrives with booking.payment_refunded |
booking.payment_paid — the fields above plus:
| field | type | description |
|---|---|---|
paidAt | string | when the payment cleared |
paymentMethod | string | card, paypal, sepa_debit, or manual for a booking settled outside Stripe |
booking.payment_refunded — the fields above plus:
| field | type | description |
|---|---|---|
refundedAt | string | when the refund went out |
refundAmountCents | number | how much came back |
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.