Skip to main content

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

  1. In the planner, go to Settings → Webhooks.
  2. Add your HTTPS URL (must be https://) and pick the events to receive.
  3. 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

EventFires whenTypical use
customer.createdA customer is addedCreate the matching CRM contact
customer.updatedA customer's details changeKeep a mirror in sync
customer.deletedA customer is removedTidy up your copy
project.createdA project is createdOpen a matching CRM record
project.updatedA project's fields changeKeep a mirror in sync
project.deletedA project is removedClose the record; its offers go too
offer.createdAn offer is created (as a DRAFT)Track quoting activity
offer.updatedAn offer is editedRefresh your copy
offer.publishedAn offer becomes visible to the customerStart a follow-up sequence
offer.acceptedA customer accepts an offerStart fulfilment / invoicing
offer.state_changedAn offer changes stateUpdate a pipeline board
offer.deletedAn offer is removedDrop it from your pipeline
material.createdA product is added to the catalogMirror your catalog
material.updatedA product is renamed, archived or re-assignedKeep the mirror fresh
material.deletedA product is removed from the catalogDrop 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 raises offer.state_changed (plus offer.published or offer.accepted), never both for the same write.
  • Cascades are reported at the top. Deleting a project raises project.deleted, not one offer.deleted per 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

fieldtypedescription
idstringthe customer id
firstNamestringgiven name
lastNamestringfamily name
emailstringcontact email
createdAtstringcustomer creation time (ISO 8601)

project.created, project.updated, project.deleted

fieldtypedescription
idstringthe project id
namestringproject name
customerIdstringthe owning customer
createdAtstringproject creation time (ISO 8601)

offer.created, offer.updated, offer.deleted

fieldtypedescription
idstringthe offer id
projectIdstringthe offer's project
namestringoffer name
statestringoffer state at the time of the event
isIndicationPricebooleanindication rather than detailed offer
createdAtstringoffer creation time

offer.published

fieldtypedescription
idstringthe offer id
projectIdstringthe offer's project
statestringalways PUBLISHED
validTostringwhen the published offer expires
createdAtstringoffer creation time

offer.accepted

fieldtypedescription
idstringthe offer id
projectIdstringthe offer's project
statestringalways ACCEPTED
acceptedAtstringwhen it was accepted
createdAtstringoffer creation time

offer.state_changed

fieldtypedescription
idstringthe offer id
projectIdstringthe offer's project
previousStatestringstate before the change
statestringthe new state
createdAtstringoffer creation time

material.created, material.updated, material.deleted

fieldtypedescription
idstringthe material id
materialTypestringwhich catalog: pv_module, battery, inverter, wallbox, equipment, misc, subconstruction, emergency_power
namestringproduct name
archivedbooleanarchived products stay on existing offers but are hidden from new ones
manufacturerIdstringthe manufacturer

Verifying deliveries

Deliveries are signed per the Standard Webhooks spec. Three headers travel with each request:

HeaderDescription
webhook-idSame as the body id — idempotency key
webhook-timestampUnix seconds at sign time — reject if skewed more than 5 minutes
webhook-signaturev1,<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);
});
}
Verify against the raw body

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 200 immediately; 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 GET if 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.