Webhooks now cover actions taken in the app

In many teams, staff work in the Smart Oversight web app while the CRM follows events from the Smart Oversight API. Until now, a client created by a colleague in the app never reached the CRM. That is no longer the case.
This article follows on from Webhooks for screenings and alerts.
Before this change
Screening and alert events already covered the whole workspace. Client events did not. The API sent client.created and client.updated only for changes made through it.
A client added by a colleague, imported from a file or archived in the app therefore went unseen by your endpoint, and so did a risk level changed by hand. The CRM kept an incomplete copy of the client base, and nothing pointed that out.
One stream, whatever the origin
Events are now generated by the services that store the data, no longer by the API. Those services see every change, whether it comes from the web app, a file import or the API.
In practice, client.created is sent when a client is created in the app, imported from a file or created through the API. client.updated is sent when a client is edited in the app or through the API, when it is archived and when its PEP or sanctions flags change.
Nothing changes for alert.resolved, screening.completed and screening.failed, which already covered both origins. alert.resolved is sent for every decision on an alert, whether it is made in the app or sent through POST /v2/alerts/{id}/review.
Your team keeps working in the app, and the CRM follows. Nobody needs to enter a client twice.
One detail changes if you already use the API. The call PUT /v2/clients/ext:<external_id>, which creates or updates a client, always sent client.updated. It now sends client.created when it creates the client, and client.updated when it changes an existing one.
What a client.updated event contains
The data field holds the client as GET /v2/clients/{id} returns it. The envelope adds the event id, its type and its creation date. Empty fields are left out.
{
"id": "evt_66f1a2b3c4d5e6f708195900",
"type": "client.updated",
"created_at": "2026-09-02T09:14:07.512Z",
"data": {
"object": "client",
"id": "cli_66f1a2b3c4d5e6f708192a3b",
"kind": "PHYSICAL",
"external_id": "CRM-1042",
"name": "John Doe",
"risk": "MEDIUM",
"management_mode": "MANAGED",
"active": true,
"sanctions": { "international": false, "administrative": false },
"address": {
"line1": "12 Rue de la Gare",
"postal_code": "1611",
"city": "Luxembourg",
"country": "LU"
},
"individual": {
"first_name": "John",
"last_name": "Doe",
"date_of_birth": "1978-04-12",
"nationalities": ["FR"],
"is_pep": false
},
"last_screened_at": "2026-09-01T16:40:12.000Z",
"created_at": "2025-11-03T10:02:44.000Z",
"updated_at": "2026-09-02T09:14:06.981Z"
}
}
data.updated_at is the time of the change. data.external_id is your own reference, set when the client is created and impossible to change afterwards. It is null when the client has none, for example if it was created in the app without one. Matching then relies on data.id, which is worth storing in the CRM.
The event does not say who made the change, nor whether it came from the app or the API. It describes the state of the client at that moment.
When the CRM receives its own changes
When your CRM updates a client through the API, a client.updated event comes back to it as well. Duplicates and loops therefore need to be avoided.
Delivery is at-least-once, so the same evt_… id can arrive twice. The simplest approach is to keep the ids already processed and ignore any that come back.
Arrival order is not guaranteed either. Keep the last updated_at applied for each client and set aside any payload that is not more recent.
That leaves recognising the echo of your own changes. If the fields the CRM maps already hold the same values on its side, the event only reflects what it has just sent, and acknowledging it is enough.
// Called after the signature has been verified and a 2xx returned.
async function handle(event) {
if (await processed.has(event.id)) return; // redelivery
await processed.add(event.id);
if (event.type !== 'client.updated') return;
const client = event.data;
const record = await crm.findBySmartOversightId(client.id)
?? (client.external_id && await crm.findByKey(client.external_id));
if (!record) return crm.createFrom(client);
if (client.updated_at <= record.lastUpdatedAt) return; // older state
if (sameMappedFields(record, client)) return; // echo of our own write
await crm.applyFrom(client); // never writes back to Smart Oversight
}
The last line matters. A handler that wrote back to Smart Oversight on every event would trigger a new one, then another, without end. Events are applied to the CRM only.
A history that follows subscriptions
The second change affects GET /v2/events. Previously, the history recorded every event, with or without a subscription. It now keeps an event only if at least one endpoint in your workspace listens for its type.
An event nobody listens for is therefore never recorded, and you will not find it later. Subscribing to a new type does not fill in the past either: the history for that type starts with the subscription.
If your tool polls GET /v2/events?type=client.updated rather than receiving notifications, subscribe an endpoint to client.updated all the same. Without it, the history for that type stays empty.
For a subscribed type, the event is recorded even if the notification does not reach you. The history is therefore the way to catch up after an outage on your side.
Going further
The integrations page lists what the Smart Oversight API covers today. To see these events in a complete synchronisation, read Connect KYC & AML screening to your CRM.
This article is provided for information only. It is not contractual and does not constitute legal advice.
Want to see the full API documentation?
Get the link by email.