Webhooks
Send data from workflow steps to external systems on state changes
Overview
Webhooks in Installer.com are configured on individual workflow steps and fire when a step transitions to a specific state. Each webhook sends an HTTP request with a JSON payload that you define using Handlebars templates, allowing you to control exactly what data is sent to your external system.
How to configure a webhook
Open the workflow editor
Go to Workflows in the Routing App. Open the workflow template you want to add a webhook to, or create a new one.
Select a step and add a notification
Click on a workflow step, then navigate to the Notifications tab. Click Add and select Webhook.
Choose the triggering state
Select which step state should trigger the webhook (e.g. active, done, rejected). See Workflow states for the full list.
Configure timing
Choose when the webhook fires relative to the state change:
| Timing | Description |
|---|---|
| On state | Fires immediately when the step enters the selected state |
| After X days | Fires X days after the step enters the selected state |
| Before X days | Fires X days before a booking date (only for steps connected to a booking) |
Set the URL, payload, and headers
- URL: The endpoint to call. Supports Handlebars variables (e.g.
https://api.example.com/orders/{{order.id}}). A step webhook is alwaysPOST; an automation webhook picks its method. - Payload: A JSON body using Handlebars templates. See Handlebars syntax for details.
- Headers: Key-value pairs sent with the request. Values support Handlebars templates.
Handlebars syntax
Webhook payloads, URLs, and headers are rendered with the Handlebars templating language. JSON strings containing Handlebars expressions ({{ }}) are replaced with actual values at execution time.
Because the standard Handlebars engine is used, all of its built-in helpers are available — including {{#each}} for iteration and {{#if}} for conditionals — in addition to the custom unix helper.
Basic variable interpolation
Use {{variableName}} or {{object.property}} to insert values:
{
"orderId": "{{order.id}}",
"orderName": "{{order.name}}",
"customerEmail": "{{order.email}}"
}Nested access
Access deeply nested properties with dot notation:
{
"installerName": "{{actor.primary-installer.organizationName}}",
"installerEmail": "{{actor.primary-installer.organizationResponsibleEmail}}"
}Variable values must be wrapped in double quotes to produce valid JSON. Writing "id": {{order.id}} (without quotes around the value) will result in invalid JSON and a failed webhook.
The unix helper
Use the unix helper to convert ISO date strings to Unix timestamps (seconds since epoch):
{
"bookingStart": "{{unix booking.startAt}}",
"bookingEnd": "{{unix booking.endAt}}"
}Iterating over offer lines
Use the built-in {{#each}} helper to loop over arrays such as offer.lines. When building a JSON array, use {{#unless @last}},{{/unless}} to add commas between items but not after the last one:
{
"orderId": "{{order.id}}",
"lines": [
{{#each latestOffer.lines}}
{
"name": "{{this.name}}",
"quantity": "{{this.quantity}}",
"unitAmount": "{{this.payerUnitAmount}}",
"productId": "{{this.productId}}",
"externalId": "{{this.externalId}}"
}
{{#unless @last}},{{/unless}}
{{/each}}
]
}Inside an {{#each}} block, this refers to the current item.
Full payload example
{
"orderId": "{{order.id}}",
"displayId": "{{order.displayId}}",
"orderName": "{{order.name}}",
"customerName": "{{order.contactPersonName}}",
"customerEmail": "{{order.email}}",
"customerPhone": "{{order.phoneNumber}}",
"address": "{{order.address}}",
"city": "{{order.city}}",
"postalCode": "{{order.postalCode}}",
"countryCode": "{{order.countryCode}}",
"state": "{{state}}",
"timestamp": "{{timestamp}}",
"bookingStart": "{{unix booking.startAt}}",
"bookingEnd": "{{unix booking.endAt}}",
"installerOrg": "{{actor.primary-installer.organizationName}}",
"installerContact": "{{actor.primary-installer.organizationResponsibleName}}",
"offer": [
{{#each latestOffer.lines}}
{
"name": "{{this.name}}",
"quantity": "{{this.quantity}}",
"unitAmount": "{{this.payerUnitAmount}}",
"productId": "{{this.productId}}",
"externalId": "{{this.externalId}}"
}
{{#unless @last}},{{/unless}}
{{/each}}
]
}Workflow states
A webhook is triggered when its step enters the configured state. The following states are available:
| State | Description |
|---|---|
active | The step is now the current step and requires action |
done | The step has been completed successfully |
skipped | The step was skipped |
rejected | The step was rejected (e.g. a dispatching was declined) |
cancelled | The step was cancelled |
expired | The step expired without being completed in time |
unconfirmed | The step is awaiting confirmation (e.g. dispatching sent to contractor) |
waiting | The step is waiting for a future event (e.g. a scheduled booking) |
overdue | The step was not completed within the expected time period |
The state variable is automatically injected into the template context with the value of the triggering state.
Available variables
All variables below can be used in the URL, payload, and header templates. Variables are populated from the order and workflow data at the time the webhook fires.
Order
| Variable | Type | Description |
|---|---|---|
order.id | string | Order UUID |
order.displayId | string | Human-readable order ID (e.g. P1-42) |
order.name | string | Order name |
order.email | string | Order contact email |
order.phoneNumber | string | Order contact phone number |
order.contactPersonName | string | Full name of the contact person |
order.firstName | string | First name (derived from contactPersonName) |
order.lastName | string | Last name (derived from contactPersonName) |
order.address | string | Street address |
order.city | string | City |
order.postalCode | string | Postal code |
order.countryCode | string | Country code (e.g. NO, NL) |
order.token | string | Order token. Step webhooks only: an automation webhook strips it, because it opens the customer portal. |
order.createdAt | string | Creation timestamp |
order.tags | array | Every tag on the order visible to your organization, each with id, name and color. Automation webhooks only. |
order.externalId | string | Your own reference for the order |
Workflow
| Variable | Type | Description |
|---|---|---|
workflow.id | string | Workflow UUID |
workflow.orderId | string | Associated order UUID |
workflow.workflowTemplateId | string | The workflow template version the order was created from. A new value after every publish. |
Router (organization)
| Variable | Type | Description |
|---|---|---|
router.id | string | Router organization UUID |
router.name | string | Organization name |
router.slug | string | Organization slug |
router.logoFileId | string | Logo file identifier |
router.locale | string | Organization locale |
router.timezone | string | Organization timezone |
router.senderId | string | SMS sender ID |
router.defaultEmailSenderName | string | Default email sender name |
router.address | string | Street address |
router.city | string | City |
router.postalCode | string | Postal code |
router.countryCode | string | Country code (e.g. NO, NL) |
router.state | string | State / region subdivision code (e.g. CA, NY) |
router.legalName | string | Legal organization name |
router.legalOrgId | string | Legal organization ID (e.g. company registration number) |
Responsible routing operator
| Variable | Type | Description |
|---|---|---|
responsibleRoutingOperator.firstName | string | Operator first name |
responsibleRoutingOperator.lastName | string | Operator last name |
responsibleRoutingOperator.name | string | Full name (first + last) |
responsibleRoutingOperator.email | string | Operator email |
responsibleRoutingOperator.phoneNumber | string | Operator phone number |
responsibleRoutingOperator.language | string | Preferred language |
Booking
Only available if a BookingCreate step exists in the workflow and is connected to the current step or its dependencies.
| Variable | Type | Description |
|---|---|---|
booking.id | string | Booking UUID |
booking.startAt | string | Booking start time (ISO 8601) |
booking.endAt | string | Booking end time (ISO 8601) |
booking.type | string | Booking type (e.g. installation, inspection) |
Offer / Latest offer
Only available if an OfferCreate step has been completed (status = Done) before the current step.
offer is the offer connected to the current step's group. latestOffer is the most recently completed offer regardless of step group.
| Variable | Type | Description |
|---|---|---|
offer.id | string | Offer UUID |
offer.name | string | Offer name |
offer.status | string | Offer status |
offer.payerTotalAmount | number | Total amount |
offer.lines | array | Offer line items |
Each item in offer.lines:
| Variable | Type | Description |
|---|---|---|
offer.lines.[index].name | string | Line item name |
offer.lines.[index].quantity | number | Quantity |
offer.lines.[index].payerUnitAmount | number | Unit amount |
offer.lines.[index].productId | string | Product id |
offer.lines.[index].externalId | string | External product id |
Step
| Variable | Type | Description |
|---|---|---|
step.id | string | Current workflow step UUID. Unique to this order. |
step.rootTemplateStepId | string | Identifies which step of the workflow this is. The same value in every order, and unchanged by a publish. |
step.title | string | Step name. Empty on a built-in step type that was never renamed. |
step.type | string | Step type (e.g. booking_create, custom, dispatching) |
step.variant | string | Booking type on a BookingCreate step (e.g. installation), otherwise empty |
step.index | number | Step position in the workflow. Renumbered when a step is added to a running order. |
Actor (dictionary by slug)
Only available for actors that have been dispatched (a Dispatching step has completed for that actor). Access actors by their slug.
| Variable | Type | Description |
|---|---|---|
actor.[slug].organizationName | string | Contractor organization name |
actor.[slug].organizationLegalName | string | Legal organization name |
actor.[slug].organizationLegalOrgId | string | Legal organization ID (e.g. company registration number) |
actor.[slug].organizationResponsibleName | string | Full name of the responsible person |
actor.[slug].organizationResponsibleFirstName | string | First name |
actor.[slug].organizationResponsibleLastName | string | Last name |
actor.[slug].organizationResponsibleEmail | string | Email address |
actor.[slug].organizationResponsiblePhoneNumber | string | Phone number |
Replace [slug] with the actor's slug from your workflow (e.g. primary-installer, inspector).
Actors (array)
The actors array contains all workflow actors with their full representative data:
| Variable | Type | Description |
|---|---|---|
actors.[index].id | string | Actor UUID |
actors.[index].slug | string | Actor slug |
actors.[index].name | string | Actor name |
actors.[index].activeRepresentative.user.firstName | string | Representative first name |
actors.[index].activeRepresentative.user.email | string | Representative email |
actors.[index].activeRepresentative.tenant.name | string | Organization name |
Active step actor and representative
| Variable | Type | Description |
|---|---|---|
activeStepActor.id | string | Actor assigned to the current step |
activeStepActor.slug | string | Actor slug |
activeStepRepresentative.user.firstName | string | Representative's first name |
activeStepRepresentative.user.email | string | Representative's email |
activeStepRepresentative.tenant.name | string | Organization name |
activeStepRepresentative is only available if the current step has an assigned actor with an active representative.
Custom fields
Custom fields are available in two formats:
By custom ID, for quick access to the field value:
| Variable | Type | Description |
|---|---|---|
customFields.[customId] | string | Value of the custom field with the given custom ID |
By field UUID, for full field metadata:
| Variable | Type | Description |
|---|---|---|
customField.[fieldId].value | string | Field value |
customField.[fieldId].type | string | Field type |
customField.[fieldId].customId | string | Custom identifier |
Custom field values are only available for fields that are required in earlier workflow steps or have default values.
State-dependent variable availability
Variables are populated based on which workflow steps have completed before the current step. If a required step has not yet been reached, the corresponding variables will be empty.
| Variable group | Requires |
|---|---|
booking | A BookingCreate step exists in the workflow and is connected to the current step or its dependencies |
offer / latestOffer | An OfferCreate step has been completed (status = Done) |
actor.[slug] | The actor has been dispatched (a Dispatching step for that actor is completed) |
activeStepRepresentative | The current step has an assigned actor with an active representative |
customFields | Fields are required in earlier steps or have default values |
Trigger
What the trigger itself was about, resolved into names rather than ids. Keys
are absent when the trigger does not carry them, so {{#if trigger.tag}} tells
you whether a tag was involved.
The {{ picker in the builder offers the subset that is specific to the
trigger you chose; the rest of the table is reachable by typing it.
| Variable | Available on | Description |
|---|---|---|
trigger.type | every trigger | The trigger the automation fired on |
trigger.at | every trigger | When the trigger happened (ISO 8601) |
trigger.isManual | every trigger | true when an operator started the run by hand |
trigger.isTest | every trigger | true on a test order |
trigger.step.* | any trigger that names a step | The step's id, type, title and index. prevStatus, nextStatus, action and actionReason only on a status change. Absent on triggers that are not about a step. |
trigger.actor.* / trigger.representative.* | any workflow.actor.* | The actor, and the organization and person the transition was about |
trigger.tag.* | a tag being assigned or removed | id, name, color |
trigger.customFieldsFilled / .customFieldsCleared | custom fields changing | Arrays of { id, customId, name, value } |
trigger.chat.* | a chat message | id, messageId, senderType. The message text is never sent. |
trigger.clock.* | a timer or schedule | anchor and dueAt. No offset: the stored value has no direction, so "two hours before" and "two hours after" would look identical. |
trigger.event.* | a timer counting from an event | The anchor event's id, type and occurredAt |
event.payload still carries the raw trigger data and is unchanged, so
existing templates keep working. trigger.* is the resolved form and is what
new templates should use.
System variables
These variables are always available regardless of which step the webhook is configured on:
| Variable | Description |
|---|---|
state | The workflow step state that triggered the webhook |
run.isManual | true when an operator started this run by hand rather than a trigger firing |
timestamp | ISO 8601 timestamp of when the webhook was triggered |
clientAppUrl | URL to the client-facing application |
routingOperatorAppUrl | URL to the routing operator application |
Retry policy (step webhooks)
This section describes webhooks configured on a workflow step. An automation webhook retries on its own schedule and is covered under Webhooks from automations.
If your endpoint returns a non-2xx status code or the request fails, Installer.com retries the delivery up to 2 times (3 attempts total) with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st attempt | Immediate |
| 2nd attempt (1st retry) | ~1 second |
| 3rd attempt (2nd retry) | ~2 seconds |
After 3 failed attempts, the delivery is marked as failed. You can view the execution logs and status in the workflow step's notification history.
The retry window is about 3 seconds in total. A deploy or restart longer than that will drop deliveries permanently, so treat a delivery as something to be accepted quickly and processed later rather than something we will keep attempting.
Webhooks from automations
An automation can also send a webhook. It shares the templating and variables described above, and adds the guarantees below. Both kinds may be enabled at once.
Retries
An automation webhook backs off on its own schedule (roughly 30 seconds, then
2 minutes, then 8 minutes, then every 25 minutes) up to the automation's
attempt limit, and honours Retry-After when you send one. A receiver that
fails repeatedly has its endpoint paused for a few minutes rather than being
hammered; deliveries queue and resume.
Signed requests
Every request is signed following the Standard Webhooks specification, so you can verify it with the standard library for your language rather than implementing ours.
| Header | Meaning |
|---|---|
webhook-id | Identifies the message. Stable across automatic retries. |
webhook-timestamp | Unix seconds, fresh on every attempt. |
webhook-signature | v1,<base64>. A space-separated list during a secret rotation, so verify against any entry. |
The signed string is {webhook-id}.{webhook-timestamp}.{raw request body}, and the
signature is HMAC-SHA256 of that string using the endpoint's signing secret. The
secret is shown once when the endpoint is created and again when it is rotated.
We also send, always and never absent:
| Header | Meaning |
|---|---|
x-installer-delivery-id | Identifies this attempt. Quote it to support. |
x-installer-webhook-attempt | 1-based attempt number. |
x-installer-webhook-test | true for a test run. |
x-installer-webhook-source | Which part of the platform produced the message. |
One more is sent whenever the message has a trigger, which is every webhook an automation sends:
| Header | Meaning |
|---|---|
x-installer-event-type | The trigger, so you can route without parsing the body. |
A header you set yourself is dropped if it uses one of these names. We always send our own value, so yours would have been overwritten anyway.
Authenticating to your endpoint
The signature above proves a request came from us. If your endpoint also requires a credential from us, an operator can attach one to the endpoint. Both options are set on the endpoint, not on the automation, so rotating them needs no republish and takes effect on a delivery already queued for retry.
A static header. Any header your API expects, for example
X-Api-Key: <key>. Values are write-only: once saved they are never returned by
the API, and only their names are shown.
OAuth2 client_credentials. We exchange a client id and secret for a bearer
token before the delivery and send it as Authorization: Bearer <token>. Tokens
are cached until shortly before they expire, so a burst of deliveries is one
grant rather than one per request.
| Field | Notes |
|---|---|
| Token URL | Where the grant is exchanged. Must be https. |
| Client ID / secret | The secret is write-only and never shown again. |
| Scope | Required by AWS Cognito; optional elsewhere. |
| Audience | Used by Auth0 and similar; ignored by Cognito. |
| Credential placement | Basic header or request body. Providers disagree on which they accept, so pick the one yours documents. |
Credentials live on the endpoint, so every automation sending to that host uses
them without repeating the configuration. If we get a 401 from your endpoint we
discard the cached token, so the next attempt uses a fresh one.
Two failure modes are worth knowing about, because they look different in the
delivery log: a failed token exchange never reaches your endpoint and is
recorded against the token step, so a provider outage does not read as your
endpoint rejecting us. A rejected grant (invalid_client, invalid_scope) is
not retried, because resending the same credentials cannot succeed.
Choosing a method
An automation webhook sends POST unless you pick otherwise. PUT, PATCH
and DELETE carry the body you write; GET sends no body at all, and no
Content-Type header with it. The method is frozen on the message alongside
the rendered bytes, so a retry repeats the verb it first sent.
Authoring against real data
The builder renders the request before you publish it. Typing {{ in the
address, a header value or the body lists what this trigger and workflow
actually populate, and Show preview renders the whole request (address,
headers and body) without sending anything.
Once a webhook is saved you can also run it against one real order from Run automation on…. A webhook may be rehearsed while it is still a draft.
A run started this way has no triggering event, so state and the
trigger-specific trigger.* variables are empty. A path that uses one fails on
a manual run even when the preview renders it.
Changes to existing behaviour
A webhook already published keeps sending what it sent before. Its step, booking, offer and representative are picked the way they always were.
The next time you publish it, it picks them from what triggered the run instead. Check these before republishing:
| Trigger | Before | After republishing |
|---|---|---|
| Timer counting from a booking | booking.* is the order's latest booking | The booking the timer fired for |
| Timer counting from a step | step.* is the first active step | The step the timer counted from |
| Actor dispatched or rejected | step.* is the first active step | The dispatching step, and actor.<slug> names the organisation the event is about |
| Step actor changed | booking.* and offer.* blank when the step owns none | The order's latest booking and offer |
A path such as /steps/{{step.id}} on a dispatch trigger posts to a different
step after republishing. On an order with one booking and one active step,
nothing changes.
Still only on workflow-step webhooks
These have no equivalent on an automation webhook yet, so a step webhook using one cannot move across:
| Feature | Note |
|---|---|
| A templated host | An automation's address keeps a literal scheme and host, which is what makes the delivery-time host check well defined. Only the path accepts variables. |
{{dnoFile}} / {{dnoFileOptional}} | The ENA Connect attachment upload runs only on the step-task path. |
| Per-order overrides | A step webhook's address and body can be edited on a single order and retried. An automation's version is immutable by design. |
Literal \n stripping | A step webhook has every literal \n removed from its body and its headers before rendering. An automation's are left alone, so a template relying on that renders differently. |
What to rely on
- Delivery is at-least-once. Dedupe on
webhook-id: the same message may arrive more than once, and every copy carries identical bytes. - Ordering is not guaranteed. Retries and independent automations interleave, so order on your own payload timestamps rather than on arrival.
- Two events can describe one fact, so also key on the subject id and event
type, not only on
webhook-id. - Reject a stale timestamp. Five minutes of tolerance is a reasonable window;
never accept
0. - We may add fields. Ignore unknown ones. Removing or renaming a field gets a new event type rather than a silent change.
- A manual resend carries a new
webhook-idand does not cancel retries that are already pending.
Responses we act on
| You return | We do |
|---|---|
2xx | Treat the delivery as successful. |
410 Gone | Disable the endpoint. Use this only when you mean permanently. |
408, 425, 429, any 5xx | Retry, honouring Retry-After when you send it. |
Any other 4xx | Treat it as a misconfiguration and stop, unless the automation opts into retrying. |
3xx | Treat it as a failure. Redirects are never followed — configure the final URL. |
The request times out after 10 seconds by default, configurable per endpoint up to
30. Only https is accepted, and literal private or link-local addresses are
refused.
Handling webhooks
Your webhook endpoint should:
- Return 2xx quickly: respond within 5 seconds to avoid timeouts
- Process asynchronously: queue the event for background processing if it requires heavy work
- Be idempotent: the same webhook may fire more than once (e.g. if a step transitions back to a previous state)
Troubleshooting
| Issue | Solution |
|---|---|
| Not receiving webhooks | Verify the step actually transitions to the configured state. Check the notification history in the workflow step for error logs. |
| Invalid JSON in payload | Ensure all Handlebars expressions are wrapped in double quotes: "{{order.id}}" not {{order.id}} |
| Empty variable values | The required preceding step may not have been completed. See State-dependent variable availability. |
| Timeout errors | Return 200 immediately and process the event asynchronously |
| Template variable error | A referenced variable is null or undefined. Verify the variable exists for the step's position in the workflow. |