The vals uses Attio webhooks to receive real-time notifications about changes to your lists. Each webhook event triggers:
- Event Reception
webhook.ts
- Validates and filters incoming events - State Storage
webhook-handler.ts
- Stores current state in SQLite - Change Detection
alert-processor.ts
- Compares states to detect changes - Message Generation
formatters.ts
- Formats changes into human readable strings - Slack Delivery
slack.ts
- Sends formatted messages to Slack
Attio webhooks return very thin events. They contain only IDs, not human-readable data or change details. In order to send human readable messages to Slack, we need do a lot of work to enrich the events with data fro the Attio API.
The most involved part in this codebase was figuring out how to transform these events into data that could be used to track the state of list entries and comments. I've accomplished this here by getting the state of the entry/comment when a webhook event comes in, storing that state in the database. We go from a webhook event that looks like this:
{ "event_type": "list-entry.updated", "id": { "workspace_id": "d1cf50ae-67ee-4109-b25a-d6223d6a780f", "list_id": "ea3b198f-859a-41fa-bb39-e0972cb71051", "entry_id": "255c52fc-85e8-4d90-8127-c6b139e3ca37", "attribute_id": "a6336f45-006c-4f08-b371-14ee9546ebee" }, "parent_object_id": "54439a10-8518-49e7-b268-41663063cdab", "parent_record_id": "6bc23aa2-5c5c-4341-949f-9177fd0e6bd5", "actor": { "type": "workspace-member", "id": "f2bbd1ae-29b5-4020-9590-f9afe2b4a276" } }
To a typescript state object that looks like this:
// Stored state includes complete API response
const stateData = {
id: "255c52fc-85e8-4d90-8127-c6b139e3ca37",
type: "entry",
action: "updated",
timestamp: 1757439109123,
listId: "ea3b198f-859a-41fa-bb39-e0972cb71051",
entryId: "255c52fc-85e8-4d90-8127-c6b139e3ca37",
rawData: {
// Stored state includes complete API response
id: { ... },
parent_record_id: "6bc23aa2-5c5c-4341-949f-9177fd0e6bd5",
parent_object: "companies",
created_at: "2025-09-09T16:55:11.986000000Z",
entry_values: {
// Crucially, includes data about the entry's attributes
stage: [
{
active_from: "2025-09-09T16:55:11.986000000Z",
active_until: null,
created_by_actor: { ... },
status: {
id: { ... },
title: "Prospecting",
...
},
attribute_type: "status",
},
],
// more attributes...
},
metadata: {...},
}};
Messages are generated by comparing consecutive states to detect changes. Diffing and message generation is done at run-time when the cron job triggers. This cuts down on the complexity of the database schema (differences and messages are not stored). By storing the entire raw API response, we also get a rich set of data that can be used to customize notifications.
Currently, the system only tracks the state of two things: list entries and comments on those list entries. The Attio webhook is set up to accept the following events:
list-entry.created
list-entry.deleted
list-entry.updated
comment.created
comment.resolved
comment.unresolved
comment.deleted
The logic of generating messages is not set up to handle states outside these two categories. The database is not set up to store data related to things other without both a list id and an entry id (see here).
This is especially relevant for comments. Attio comments can be associated with a record or a list entry (among other things). When a comment is added to a record, it is associated with the record, not the list entry. When a comment is added to a list entry, it is associated with the list entry, not the record. Because of this, you will not see notifications for comments on records, even if those records are in a list you track.
See this Attio help center article for more on Attio's data model. See this Attio API documentation for more information on the types of events that Attio can send.
The diagram below gives a more complete picture of how data flows through the system.