Webhooks
Endatix provides a flexible webhook system that allows you to send notifications to external systems when specific events occur. This guide will show you how to configure webhooks at different levels.
Overview
Webhooks in Endatix are built-in event handlers that automatically send HTTP requests to configured endpoints when specific events occur. The system supports the following events:
- Form events (
FormCreated,FormUpdated,FormEnabledStateChanged,FormDeleted) - Submission events (
SubmissionCompleted,SubmissionClosed)
Delivery is durable. The API writes an outbox message in the same database transaction as the form or submission, then returns. A background relay picks the message up. With Endatix:Outbox:DeliverToJobQueue on, that relay enqueues one background job per endpoint. Each job sends, retries, and dead-letters on its own, so one slow endpoint does not hold the others. With the switch off (the default in appsettings.json), the relay delivers every endpoint inline. See Background Processing.
Configuration Levels
Webhooks can be configured at two levels:
- Tenant level - Default webhook settings that apply to all forms within a tenant
- Form level - Specific settings for individual forms that override the tenant settings
Form-level webhook settings completely override tenant-level settings. When a form has its own webhook configuration, the tenant-level settings are ignored for that form.
Webhook Settings Format
Both tenant and form webhook configurations use the same JSON format:
{
"Events": {
"FormCreated": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://your-endpoint.com/webhook",
"Authentication": {
"Type": "None"
}
}
]
},
"SubmissionCompleted": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://your-endpoint.com/webhook",
"Authentication": {
"Type": "ApiKey",
"ApiKeyHeader": "X-API-KEY",
"ApiKey": "your-secret-key"
}
}
]
},
"SubmissionClosed": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://your-endpoint.com/webhook",
"Authentication": {
"Type": "ApiKey",
"ApiKeyHeader": "X-API-KEY",
"ApiKey": "your-secret-key"
}
}
]
}
}
}
This example shows FormCreated, SubmissionCompleted, and SubmissionClosed. FormUpdated, FormEnabledStateChanged, and FormDeleted use the same shape. The same document goes in a form's webHookSettingsJson and in the tenant's WebHookSettingsJson column.
SubmissionClosed replaces SubmissionCollectionStatusChanged. The upgrade migration moves the stored key on every form and tenant, keeping its enabled flag and endpoints. For this release a stored SubmissionCollectionStatusChanged still counts as SubmissionClosed when that key is absent, so settings an older instance or Hub saved after the migration keep delivering. A form create or update that sends SubmissionCollectionStatusChanged is rejected with a 400: upgrade Endatix Hub together with the API.
Stop every API instance of the previous version before you start this one; don't roll the update. Once the migration has run, an older instance no longer finds endpoints under the renamed key, and it drops the new submission.closed outbox rows without sending a webhook. On Kubernetes, scale the API deployment to 0 replicas before you deploy this version, or set its strategy: Recreate for this release, then go back to your usual strategy.
Tenant-Level Configuration
Tenant webhook settings are stored in the TenantSettings table in the WebHookSettingsJson column. These settings serve as the default webhook configuration for all forms belonging to the tenant.
Currently, tenant webhook settings must be configured directly in the database. An API endpoint for managing tenant settings is expected in a future release.
Form-Level Configuration
Form webhook settings can be configured via the Forms API endpoints. The webHookSettingsJson field accepts the same JSON format as tenant settings.
API Endpoints
POST /api/forms- Create a new form with webhook settingsPUT /api/forms/{formId}- Update a form (full update) with webhook settingsPATCH /api/forms/{formId}- Partially update a form with webhook settings
Important Notes on the webHookSettingsJson Field
- The field is optional - omit it entirely to use tenant-level settings
- To remove form-level overrides and fall back to tenant settings, do not include the field in your request
- Providing an empty object
"{}"will override the settings with an empty configuration (no webhooks will fire for this form)
Example: Creating a Form with Webhooks
curl -X POST https://your-api.com/api/forms \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"name": "Contact Form",
"isEnabled": true,
"webHookSettingsJson": "{\"Events\":{\"SubmissionCompleted\":{\"IsEnabled\":true,\"WebHookEndpoints\":[{\"Url\":\"https://your-endpoint.com/webhook\",\"Authentication\":{\"Type\":\"None\"}}]}}}"
}'
Example: Updating Form Webhooks
curl -X PATCH https://your-api.com/api/forms/{formId} \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-token" \
-d '{
"webHookSettingsJson": "{\"Events\":{\"SubmissionCompleted\":{\"IsEnabled\":true,\"WebHookEndpoints\":[{\"Url\":\"https://new-endpoint.com/webhook\",\"Authentication\":{\"Type\":\"None\"}}]}}}"
}'
Webhook Authentication
Endatix supports configurable authentication for securing your webhook endpoints. You can configure authentication per endpoint to match your API requirements.
Authentication Types
1. API Key Authentication (ApiKey)
Sends an API key in a custom header:
{
"Url": "https://api.example.com/webhooks",
"Authentication": {
"Type": "ApiKey",
"ApiKeyHeader": "X-SURVEY-API-KEY",
"ApiKey": "your-secret-api-key"
}
}
2. No Authentication (None)
No authentication headers are sent:
{
"Url": "https://api.example.com/webhooks",
"Authentication": {
"Type": "None"
}
}
Testing Webhooks
You can use webhook.site to test your webhook implementation. This service provides a unique URL that you can use as your webhook endpoint. It will display all incoming requests in real-time, allowing you to:
- View the complete request payload
- Inspect headers
- See the exact format of the data being sent
- Test different event types
- Verify the webhook message structure
To test your webhooks:
- Go to webhook.site
- Copy the unique URL provided
- Configure this URL as your webhook endpoint (either at tenant or form level)
- Trigger events in your application
- View the incoming webhook requests on webhook.site
Example Test Configuration
{
"Events": {
"FormCreated": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://webhook.site/your-unique-id",
"Authentication": {
"Type": "None"
}
}
]
},
"FormUpdated": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://webhook.site/your-unique-id",
"Authentication": {
"Type": "None"
}
}
]
},
"SubmissionCompleted": {
"IsEnabled": true,
"WebHookEndpoints": [
{
"Url": "https://webhook.site/your-unique-id",
"Authentication": {
"Type": "None"
}
}
]
}
}
}
Available Events
Form Events
-
FormCreated: Triggered when a new form is created- Endpoints that can trigger the event:
POST api/forms
- Useful for synchronizing form data with external systems or triggering notifications
- Endpoints that can trigger the event:
-
FormUpdated: Triggered when a form is updated- Endpoints that can trigger the event:
PUT api/forms/{formId}PATCH api/forms/{formId}
- This event is triggered when performing form update action even if all the data remains the same
- Can be used to track form changes and update related systems
- Endpoints that can trigger the event:
-
FormEnabledStateChanged: Triggered when a form's enabled state changes- Endpoints that can trigger the event:
PUT api/forms/{formId}PATCH api/forms/{formId}
- This event is triggered only when the value of
IsEnabledfield changes - Useful for managing form availability in external systems
- Endpoints that can trigger the event:
-
FormDeleted: Triggered when a form is deleted- Endpoints that can trigger the event:
DELETE api/forms/{formId}
- This event is triggered when a form is soft-deleted
- Useful for cleaning up related data in external systems or triggering notifications
- Endpoints that can trigger the event:
Submission Events
-
SubmissionCompleted: Triggered when a form submission is completed- Endpoints that can trigger the event:
POST api/forms/{formId}/submissionsPUT api/forms/{formId}/submissions/{submissionId}PATCH api/forms/{formId}/submissions/{submissionId}PATCH api/forms/{formId}/submissions/by-token/{submissionToken}
- This event is triggered once per submission when
IsCompleteis set to true - A screen-out does not send this event
- Can be used to process form responses, trigger workflows, or update external systems
- Endpoints that can trigger the event:
-
SubmissionClosed: Triggered once when a submission ends in a closed outcome without completing:screen_out,quota_full,cancelled, or a closed code your host registered.isCompletestays false- Endpoints that can trigger the event: the same public create and token update calls, with
collectionOutcomeset to a closed status such asscreen_out. A respondent save can't sendquota_fullorcancelled; only the server sets them - The payload carries
collectionStatusandpreviousCollectionStatus, each as{ "code": "screen_out", "phase": "closed" }. It carries no answers (jsonData) - A closed submission can't move again, so a single save sends this event once. Two saves that reach the same submission at the same moment can both commit and each send it, with its own
X-Endatix-Hook-Id, until #1181 ships. See Deduplicate deliveries expiredandabandonedare lapsed statuses, not closed ones: the submission can still resume, and they send no webhook- An in-process
INotificationHandlerdoes not run for this event. Code in the API host implementsIOutboxIntegrationEventHandler. See Submission lifecycle events.
- Endpoints that can trigger the event: the same public create and token update calls, with
Delivery depends on the event type only. These are the submission webhooks and the outbox events they deliver:
Webhook eventName | Settings key | Outbox event it delivers |
|---|---|---|
submission_completed | SubmissionCompleted | submission.completed |
submission_closed | SubmissionClosed | submission.closed |
Every other submission lifecycle event (submission.started, submission.lapsed, and the rest) is an outbox event only. See Background Processing.
Webhook Message Structure
Each webhook message is a JSON object with four camelCase fields. Ids and revision are JSON strings, so a 64-bit id keeps every digit in JavaScript:
id: Unique Snowflake-generated webhook message ideventName: The type of event (e.g.,form_created,form_updated,form_enabled_state_changed,form_deleted,submission_completed,submission_closed)action: The action that triggered the event (e.g.,created,updated,deleted)payload: The event data specific to the operation, containing the complete entity data. It includes explicit entity identifier fields, such asformIdfor form events andsubmissionIdfor submission events, and arevisionfield - a per-entity counter that increases with every change, which consumers can use to order events for the same form or submission. A submission's revision also rises with lifecycle events that are not webhooks (submission.created,submission.started), so a webhook-only consumer sees gaps that are not missed updates- Submission event payloads (
submission_completed,submission_closed) carryisTestSubmission:truefor a test submission,falsefor a real one. It is the value when the event was raised. Marking a submission as test or real later does not send a webhook: it raises the outbox eventsubmission.updated, which carries the new value to outbox subscribers
- Submission event payloads (
Request Headers
Every webhook request carries these headers:
| Header | Value |
|---|---|
X-Endatix-Event | The event name (e.g., submission_completed) |
X-Endatix-Entity | The entity type (Submission or Form) |
X-Endatix-Action | The action (e.g., created, updated, deleted) |
X-Endatix-Hook-Id | The unique message id - equals the id field in the body |
X-Endatix-Hook-Id identifies the message, not the delivery attempt: if the same event is redelivered (delivery is at-least-once), the header repeats verbatim. Use it as the deduplication key for idempotent processing.
Authentication headers (such as an API key) are added according to your webhook authentication configuration.
React to a screen-out
A screen-out reaches your endpoint as one submission_closed webhook whose payload.collectionStatus.code is screen_out. Endatix 0.9.1 replaced the old screen-out webhook (submission_collection_status_changed) with this one. At the end of this section your endpoint checks the API key, acts on real screen-outs only, and ignores duplicates.
-
Subscribe an endpoint to
SubmissionClosed, at the form or tenant level. In Endatix Hub, open the form and turn on Submission Closed under Webhooks. Tenant settings are set in the database (see Tenant-Level Configuration).{"Events": {"SubmissionClosed": {"IsEnabled": true,"WebHookEndpoints": [{"Url": "https://your-endpoint.com/webhooks/endatix","Authentication": {"Type": "ApiKey","ApiKeyHeader": "X-API-KEY","ApiKey": "your-secret-key"}}]}}} -
Accept the request only when the API key header matches.
-
Act only when
eventNameissubmission_closedandpayload.collectionStatus.codeisscreen_out. The same webhook also carriesquota_full,cancelled, and closed codes your host registered. -
Route test submissions (
payload.isTestSubmissionistrue) away from your real counts. -
Skip a message you already handled. Key it on the submission id, the event, and the revision.
-
Answer with a 2xx status. Any other status, or a timeout, makes Endatix retry the delivery.
What your endpoint receives
A respondent who screens out on a public form produces this request body. It was captured from Endatix API 0.9.1:
{
"id": "1569960311241310208",
"eventName": "submission_closed",
"action": "updated",
"payload": {
"formId": "1569960310264037376",
"revision": "3",
"tenantId": "1",
"occurredAt": "2026-11-11T06:21:46.650432Z",
"submissionId": "1569960310972874752",
"collectionStatus": {
"code": "screen_out",
"phase": "closed"
},
"formDefinitionId": "1569960310280814592",
"isTestSubmission": false,
"previousCollectionStatus": {
"code": "in_progress",
"phase": "active"
}
}
}
The request carries X-Endatix-Event: submission_closed, X-Endatix-Entity: Submission, X-Endatix-Action: updated, X-Endatix-Hook-Id (equal to id), and your API key header. The payload has no answers (jsonData): read the submission through the API when you need them.
A test submission created on behalf of a participant that then screens out sends the same shape with "isTestSubmission": true, and previousCollectionStatus is { "code": "not_started", "phase": "pending" } when the participant had not answered yet.
Receiver sample
A Node.js 20 or later receiver with no dependencies. Run it with WEBHOOK_API_KEY=your-secret-key node receiver.mjs:
// receiver.mjs - run with: WEBHOOK_API_KEY=... node receiver.mjs
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
const PORT = Number(process.env.PORT ?? 8080);
const API_KEY_HEADER = "x-api-key"; // Node lowercases header names
const API_KEY = Buffer.from(process.env.WEBHOOK_API_KEY ?? "");
// Use a durable store (a unique index in your database) in production.
const seen = new Set();
function hasValidApiKey(request) {
const sent = Buffer.from(request.headers[API_KEY_HEADER] ?? "");
return API_KEY.length > 0 && sent.length === API_KEY.length && timingSafeEqual(sent, API_KEY);
}
async function readJson(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
async function handleScreenOut(payload) {
console.log(`screen-out: submission ${payload.submissionId} on form ${payload.formId}`);
}
async function handleTestScreenOut(payload) {
console.log(`test screen-out, not counted: submission ${payload.submissionId}`);
}
createServer(async (request, response) => {
if (request.method !== "POST") return response.writeHead(405).end();
if (!hasValidApiKey(request)) return response.writeHead(401).end();
let message;
try {
message = await readJson(request);
} catch {
return response.writeHead(400).end();
}
const { eventName, payload } = message;
if (eventName !== "submission_closed" || payload?.collectionStatus?.code !== "screen_out") {
return response.writeHead(204).end();
}
const key = `${payload.submissionId}:${eventName}:${payload.revision}`;
if (seen.has(key)) return response.writeHead(204).end();
try {
await (payload.isTestSubmission ? handleTestScreenOut(payload) : handleScreenOut(payload));
seen.add(key);
response.writeHead(204).end();
} catch (error) {
console.error(error);
response.writeHead(500).end(); // Endatix retries a failed delivery
}
}).listen(PORT, () => console.log(`listening on ${PORT}`));
Replace the in-memory Set with a unique index in your own database, so a restart or a second instance keeps the history.
Deduplicate deliveries
Endatix delivers at least once. Two cases send a message you have already seen:
- A retry repeats the message with the same
X-Endatix-Hook-Id. - Concurrent saves. Two saves that reach the same submission at the same moment can both commit, and each sends its own message with its own
X-Endatix-Hook-Id. The pair can be two screen-outs, or a screen-out and asubmission_completed. This stays possible until #1181 ships.
The Hook-Id alone does not catch the second case, so key on payload.submissionId, eventName and payload.revision, as the sample does. A completed or closed submission never moves again, so the first submission_completed or submission_closed for a submission id is its outcome: when your system counts outcomes, keep only that first one per submission id, which also settles a screen-out and a completion that raced.
Self-hosted: run the code inside the API
When your code runs in the Endatix API host, skip the webhook and subscribe to the outbox event submission.closed instead. See React to a screen-out in Background Processing for the ScreenOutHandler sample.
Best Practices
- Error Handling: Design your webhook receiving endpoints to handle errors gracefully
- Idempotency: Use the
X-Endatix-Hook-Idheader to deduplicate at-least-once deliveries. With per-endpoint jobs, a retry of one endpoint repeats that header and does not resend the other endpoints. For submission events, also key on the submission id, event and revision: see Deduplicate deliveries. - Logging: Log incoming webhook requests for debugging and auditing
- Validation: Validate the payload structure and required fields before processing
- Use Form-Level Sparingly: Only configure form-level webhooks when you need different behavior than the tenant default