A webhook is an HTTP request Dotfile sends to your server when something changes in a workspace, so an integration learns about a case, an entity or a check without polling for it.
There are 46 subscribable events, across nine families:
| Family | Events | Covers |
|---|---|---|
Case | 17 | Creation, deletion, every property update, periodic review, client portal wait steps |
CaseReport | 1 | Report generation |
Note | 3 | Notes on a case |
NoteComment | 3 | Comments on a note |
Individual | 6 | Creation, deletion, property updates, relevance |
Company | 6 | Creation, deletion, property updates, relevance |
Check | 6 | Start, review needed, approval, rejection, expiry, deletion |
DocumentOrder | 2 | Company document retrieval |
AutonomyChatRun | 2 | Terminal outcome of an Autonomy run |
Configuring webhooks
Receiving webhooks takes a dedicated endpoint on your server and a webhook registered against it through Create a webhook. Dotfile then sends POST requests with a raw JSON body to that endpoint. A workspace holds up to 50 webhooks.
Every delivery is recorded. The log — the payload sent, and each attempt with its response — is readable in the Dotfile console and through List all webhook logs. Logs are retained for 30 days by default.
Events
Case events
Case.Created: When a new case is created (empty case).Case.Updated: When any case property (such asname,external_id,status,flags,risk,tags,template_id,metadata,custom_properties,assignee) is updated. Asub_eventis always provided inCase.Updatedevents and can be valued to any of the following events:Case.StatusUpdated: When case status is updated from one status to another one.
Example: caseopen→approvedCase.FlagsUpdated: When case flags are updated (any flag changes can trigger this event).Case.ContactHasActionsUpdated: Whencontact_has_actionsis updated.Case.ReviewerHasActionsUpdated: Whenreviewer_has_actionsis updated.Case.InfoUpdated: When case info is updated (such asname,external_idorcustom_properties).Case.TemplateUpdated: When case template is updated.Case.RiskUpdated: When the case current risk is updated.Case.MetadataUpdated: When case metadata is updated.Case.TagsUpdated: When tags have been updated on case. Adding a tag and removing a tag both surface as this single event.Case.AssigneeUpdated: When the case assignee is set, changed or cleared.
Case.ReviewUpdated: When the case periodic review is updated (for example when the case’s risk changes and thenext_review_atdate is updated).Case.ReviewDue: When the case periodic review is due.Case.ReviewConfirmed: When the case periodic review is confirmed.Case.ClientPortalWaitStepTriggered: When an applicant reaches a wait step in a client portal workflow, the first time that step is reached. The case is parked until you call Complete client portal wait step.Case.Deleted: When a case is deleted.
Case report events
CaseReport.Generated: When a case report link is generated. Only report generations requested through the API emit this event.
Note events
Note.Created: When a new note is created.Note.Updated: When any note content is updated.Note.Deleted: When a note is deleted.
Note comment events
NoteComment.Created: When a new note comment is created.NoteComment.Updated: When any note comment content is updated.NoteComment.Deleted: When a note comment is deleted.
Individual events
Individual.Created: When a new individual is created.Individual.Updated: When any individual property (not check related) is updated, for examplerisk. Asub_eventis always provided inIndividual.Updatedevents and can be valued to any of the following events:Individual.InfoUpdated: When individual properties are updated.Individual.MarkedAsRelevant: When an irrelevant individual is marked as relevant.Individual.MarkedAsNotRelevant: When a relevant individual is marked as not relevant.
Individual.Deleted: When an individual is deleted.
Company events
Company.Created: When a new company is created.Company.Updated: When any company property (not check related) is updated, for examplerisk. Asub_eventis always provided inCompany.Updatedevents and can be valued to any of the following events:Company.InfoUpdated: When company properties are updated.Company.MarkedAsRelevant: When an irrelevant company is marked as relevant.Company.MarkedAsNotRelevant: When a relevant company is marked as not relevant.
Company.Deleted: When a company is deleted.
Check events
Check.Started: When a check is started on an entity.
Example: an AML check is started on an individual.Check.ReviewNeeded: When a check needs manual review.
Example: an AML check found hits and a reviewer needs to look at them.Check.Approved: When a check is approved.
Example: all hits are ignored as false positives and the reviewer approves the AML check.Check.Rejected: When a check is rejected.
Example: a document is invalid and the reviewer rejects the document check.Check.Expired: When a check is expired.
Example: a document check has passed its expiration date.Check.Deleted: When a check is deleted.
Example: a document check has been removed by a user.
A review recorded through the API — PATCH /v1/checks/{type}/{id}/review — emits the same Check.Approved and Check.Rejected events as a review made in the Console App. The payload’s reviewer is api instead of a user.
Document order events
DocumentOrder.Completed: When a document order for a company has been completed.
Example: the KBIS for a French company has been retrieved.DocumentOrder.Failed: When a document order for a company has failed.
Example: annual accounts are unavailable for a company.
Autonomy chat run events
AutonomyChatRun.Completed: When a chat run reaches its end normally. The payload carries thethread_id, therun_id, theoutcome, the run’slast_message, and theroutine_idandroutine_slugwhen the run was fired by a routine.AutonomyChatRun.Failed: When a run ends onabortedorerror.
A thread runs once per user message or wake-up, and each terminal run fires its own event. POST /v1/routines/{slug}/trigger returns the thread_id these events carry, so an execution you started is matched to its outcome without a lookup.
Note:
Deletedevents do not trigger the deleted event of sub-entities.Example: deleting a case triggers a
Case.Deletedevent, but noIndividual.Deleted,Company.DeletednorCheck.Deletedevent.
Note: events can have sub events for fine-grained filtering.
For instance, the top-level event
Case.Updatedhas a sub-eventCase.StatusUpdatedthat lets you subscribe specifically to updates of the casestatusand ignore updates of other properties.The top-level event is always triggered, so you never need to subscribe to a sub-event you already cover through its parent. Subscribing one webhook to both a parent event and one of its sub-events is refused: the request returns
400with codeINVALID_WEBHOOK_CONFIGURATION. Pick one level per webhook.
Main Event vs. Sub event
For example, if you subscribe to the event Case.Updated, the event structure is as follows:
- Main Event:
Case.Updated - Sub event:
Case.StatusUpdated— or any otherCase.*Updatedsub event
You can also subscribe specifically to a sub event such as Case.StatusUpdated, then the event structure will be as follows:
- Main Event:
Case.StatusUpdated - Sub event:
None
Best Practices
- Fine-grained control: Subscribe to specific events like
Case.StatusUpdatedif you only need information about status changes. - Broad updates: Subscribe to
Case.Updatedif you want to receive all types of case updates, including status changes. - Filtering: If subscribed to
Case.Updated, implement filtering on your side to handle specific sub events as needed.
Payload
Payload structure:
{
"event": "ENTITY_NAME.ACTION_NAME",
"sub_event": "ENTITY_NAME.SUB_ACTION_NAME",
"context": { "...": "..." },
"entity_name": { "...": "..." }
}sub_event is only present on the three parent events (Case.Updated, Individual.Updated, Company.Updated).
The key carrying the entity depends on the event:
| Event | Entity key |
|---|---|
| Case.* | case — the full case object, or id, name and external_id only for Case.Deleted |
| CaseReport.* | report |
| Note.* | note |
| NoteComment.* | noteComment |
| Individual.* | individual |
| Company.* | company |
| Check.* | check |
| DocumentOrder.* | document_order |
| AutonomyChatRun.* | no wrapper — thread_id, run_id, outcome, last_message, routine_id and routine_slug sit at the top level |
Context
You can find useful extra properties in the context such as:
timestampa UNIX timestamp of the time when the webhook is processed. You can verify that this timestamp is within 1-2 minutes of the time your system receives it to prevent replay attacks.event_idUUID of the event. When a webhook is retried the event id is the same as the initial call.retry_countWebhook retry counter. 0 for the initial call.remaining_retry_countWebhook automatic remaining retry counter.
The context also contains additional properties depending on the entity targeted by the webhook.
| Event | context properties |
|---|---|
| Case.* | workspace |
| CaseReport.* | workspace, case |
| Note.* | workspace, case |
| NoteComment.* | workspace, case, note |
| Individual.* | workspace, case |
| Company.* | workspace, case |
| Check.* | workspace, case, individual, company |
| DocumentOrder.* | workspace |
| AutonomyChatRun.* | workspace |
workspace: contains a workspace subset with itsidandname.case: contains a case subset with itsid,external_id,last_activity_at,name,tags,contact_has_actions,reviewer_has_actions,flags,metadataandstatus. If the case was created via a client portal, the case context will also include aclient_portalsubset, withid,nameandtype.note: contains a note subset with itsidandauthor(author.id,author.first_name,author.last_name,author.email).authorisnullfor a note that was not written by a user.individual: contains an individual subset withid,first_name,last_name.company: contains a company subset withid,name,country.
On Check.* events, individual and company are mutually exclusive: the one the check does not run on is null.
The context can be useful to verify that the webhook comes from a specific workspace or build URL to the Console App.
You can see all payload definition documented in the Callbacks section (latest section of Create a webhook).
Testing a webhook
A webhook needs a reachable URL at creation time, which makes it awkward to develop against.
webhook.site generates a unique URL and displays the incoming HTTP request in the browser, which is enough to inspect a payload.
ngrok generates a unique URL that forwards to a local port, so a webhook created with that URL delivers to a local machine.
> ngrok http http://localhost:4000/
# will generate an URL like https://0ebb-92-8-30-12.eu.ngrok.ioSecuring a webhook
Every payload is signed. The signature is an HMAC-SHA256 of the request body, keyed with the webhook secret and hex-encoded, sent in the Dotfile-Signature header.
The secret is returned when the webhook is created. Leave secret out of the payload and Dotfile generates one, prefixed dotsecret.. Sending a new secret on Update a webhook rotates it.
Note: secret rotation happens with no downtime.
After a rotation, a second header
Dotfile-Old-Signaturecarries the signature computed with the previous secret, so an endpoint validating against either one keeps accepting deliveries during the switch. See the example below.
To verify a payload, compute the signature from the request body using the webhook secret and compare. Hash the raw request body: parsing the JSON first may alter it.
A minimal Express implementation:
import express from 'express';
import bodyParser from 'body-parser';
import { createHmac } from 'crypto';
const app = express();
const port = 3000;
// Replace with your webhook secret
const WEBHOOK_SECRET = 'dotsecret.XXXXXXXXXX';
// Add middleware to extract raw request body
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString();
},
})
);
// Parse the request body
app.use(bodyParser.json());
// Receive HTTP POST requests
app.post('/dotfile-webhook', (req, res) => {
const payload = req.body;
const rawBody = req.rawBody;
// Verify signature
const signature = createHmac('sha256', WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
let isSignatureValid = false;
if (signature === req.headers['dotfile-signature']) {
isSignatureValid = true;
console.log('Valid signature');
}
if (signature === req.headers['dotfile-old-signature']) {
isSignatureValid = true;
console.log('Valid signature but has been rotated, update your config');
}
if (!isSignatureValid) {
res.sendStatus(400);
console.error('Invalid signature');
return;
}
// Handle the payload
console.log(payload);
// Respond 200 to acknowledge receipt
res.sendStatus(200);
});
app.listen(port, () =>
console.log(`Webhook consumer listening on port ${port}`)
);context.timestamp carries the UNIX timestamp at which the payload was processed. Rejecting a payload whose timestamp is more than one or two minutes older than the time of receipt guards against replay attacks.
Webhook ordering
Overview
Webhooks are delivered asynchronously, so events are not guaranteed to arrive in the order they were triggered. Network delays, retries and asynchronous processing all reorder deliveries, and this is common to webhook systems generally. Background on the trade-off: Svix on guaranteeing webhook ordering and the accompanying discussion.
Design decision
Delivery is guaranteed; order is not. That trade-off is deliberate, and it is what allows the system to absorb network and endpoint failures without degrading.
Enforcing strict ordering would mean holding every subsequent event for an entity until the blocking one had been delivered successfully and acknowledged without error. A single unhealthy endpoint would then accumulate an unbounded backlog, which moves the reliability problem onto the integration rather than removing it.
Some integrations do need ordered state. The two techniques below reconcile that with unordered delivery.
Synchronization best practices
-
Re-read the current state
After receiving a webhook, call
GET /cases/{id}to retrieve the case as it stands. The webhook becomes a trigger rather than a source of truth, and late deliveries cannot corrupt local state. -
Compare on
last_activity_atEvery case carries a
last_activity_attimestamp. Storing it locally and applying an update only when the incominglast_activity_atis greater than or equal to the stored one discards stale deliveries without discarding the event.
Failure and retry
A delivery is successful when the endpoint answers 2XX. Any other response code is a failure, as is a call that cannot complete at all — connection refused, DNS failure, timeout — which is recorded as 502. Failures are retried with an exponential backoff until a 2XX is returned or the retries are exhausted.
There are 4 automatic retries after the initial call, at 1 hour, 4 hours, 13 hours and 40 hours from that call:
| Retry | After the initial call | After the previous retry |
|---|---|---|
| 1st | 1 hour | — |
| 2nd | 4 hours | 3 hours |
| 3rd | 13 hours | 9 hours |
| 4th | 40 hours | 27 hours |
Retries read the webhook's URL at the moment they run, so correcting a broken URL redirects the retries still pending.
Retry state travels in both the payload context and the request headers:
| Context property | Header | Value |
|---|---|---|
context.event_id | dotfile-event-id | UUID of the event. A retry carries the same id as the initial call. |
context.retry_count | dotfile-retry-count | Retry counter. 0 on the initial call. |
context.remaining_retry_count | dotfile-remaining-retry-count | Automatic retries left, out of a maximum of 4. |
Note: a webhook call can be inspected and retried manually from the Dotfile console.
A successful manual retry cancels the automatic retries still pending for that call.
An unsuccessful one counts against the automatic retry budget, and the next automatic retry, if any, still falls on its interval from the initial call.
Warning: a webhook is set offline automatically
Once a webhook reaches 50 failed calls within a rolling 24h window, it enters a high error rate state and a notification is sent. The window never reaches further back than the moment the webhook was last set online.
A webhook that is not LIVE is then set offline. Correcting the URL and setting the webhook back online, through the API or the console, restarts delivery.
A webhook promoted to LIVE stays online through a high error rate, and still raises the notification. Promotion is done in the console, and a workspace holds up to 3 LIVE webhooks. LIVE is also the condition for bulk-retrying a range of failed calls from the console.