alert.sent webhook
The alert.sent event is delivered whenever one of your Customerscore alerts matches at least one customer. It contains the alert's configuration, the list of matching customers (with their score/property changes), and batching metadata so you can react to alerts in your own systems — Slack, a CRM, a data warehouse, etc.
Heads-up on batching: a single alert run can match thousands of customers. Customerscore splits the matches into batches of up to 1000 customers and sends one
alert.sentrequest per batch. Always read thebatchobject (below) to know whether more requests are coming for the same run.
When it's triggered
An alert.sent request is dispatched when all of the following are true:
- Your account has at least one active webhook subscribed to the
alert.sentevent. - An alert is evaluated and one or more customers match its conditions. Alerts that match zero customers do not produce a webhook.
Alerts are evaluated in two situations:
| Trigger | Description |
|---|---|
| Automatic | After every data sync / import finishes processing, all of your alerts are re-evaluated against the new snapshot. This is the normal, day-to-day trigger. |
| Manual | When an alert is sent on demand from the app (the "Send" action on an alert). |
For each evaluation that produces matches, customers are split into batches of 1000 and a webhook request is sent per batch.
Delivery
| Property | Value |
|---|---|
| HTTP method | POST |
Content-Type | application/json |
User-Agent | Customerscore.io-Webhook/1.0 |
| Signature header | X-customerscore-HMAC: sha256=<hex> (see Verifying the signature) |
| Timeout | 10 seconds |
| Retries | Up to 3 attempts, exponential backoff (first retry after ~5s) |
Response handling:
- 2xx — treated as a successful delivery; no retry.
- 5xx — treated as a transient failure and retried (up to the retry limit).
- 4xx — treated as a permanent failure; not retried. Make sure your endpoint returns a 2xx once it has accepted the payload.
Your endpoint should respond quickly (within the 10s timeout). If you need to do heavy processing, acknowledge with a 2xx immediately and process the payload asynchronously.
Payload schema
Every webhook is wrapped in a common envelope:
{
"event": "alert.sent",
"timestamp": "2025-12-31T23:59:59.999Z",
"data": { ... }
}| Field | Type | Description |
|---|---|---|
event | string | Always "alert.sent" for this event. |
timestamp | string (ISO-8601) | When the webhook payload was generated (UTC). |
data | object | The alert payload — see below. |
data
| Field | Type | Nullable | Description |
|---|---|---|---|
date | string (ISO date, YYYY-MM-DD) | no | The snapshot date the alert was evaluated against (the latest snapshot). |
previous_date | string (ISO date) | yes | The previous snapshot date used to compute changes. null if there is no previous snapshot. |
alert | object | no | The alert configuration — see data.alert. |
customers | array | no | Matching customers in this batch — see data.customers. |
batch | object | no | Batching metadata — see data.batch. |
data.alert
| Field | Type | Nullable | Description |
|---|---|---|---|
name | string | no | The alert's name (max 60 chars). |
description | string | yes | The alert's description (max 500 chars). |
emails | string[] | no | Email recipients configured on the alert. |
alert_condition | object | no | The condition that fired — see alert_condition. |
segment | object | no | { "name": string | null }. The segment the alert is scoped to, or null when the alert targets all customers. |
Note: an alert may technically have more than one condition configured, but the webhook payload reports the first/primary condition as a single object.
data.alert.alert_condition
| Field | Type | Description |
|---|---|---|
type | string | The kind of comparison. See Condition types. |
filter_by_type | string | The category of value being evaluated. See Filter categories. |
filter_by | string | The specific field within the category. See Filter categories. |
operator | string | The comparison operator (UPPERCASE). See Operators. |
value | string | The threshold the value is compared against. Always a string. For percentage conditions this is a decimal fraction (e.g. "0.4" means 40%). |
data.customers
Each entry represents one matching customer.
| Field | Type | Present when | Description |
|---|---|---|---|
name | string | always | The customer's name. |
external_id | string | always | Your external identifier for the customer (the ID you sync to CustomerScore). |
current_value | number | string | standard & attribute_change alerts | The customer's value on date. A number for standard alerts; for attribute_change alerts it is the changed-to attribute value and may be a string. |
previous_value | number | standard alerts only | The customer's value on previous_date. |
difference_abs | number | standard alerts only | Absolute change (current_value − previous_value). |
difference_pct | number | standard alerts only | Relative change as a decimal fraction (e.g. -0.22 = −22%). |
Which value fields you get depends on the alert type:
- Standard (
percentage,absolute,treshold) — each customer includes all four value fields:current_value,previous_value,difference_abs,difference_pct.attribute_change— there is no numeric before/after comparison, so onlycurrent_valueis included (the new attribute value, which may be a string).previous_value,difference_abs, anddifference_pctare omitted.churned— there is no value comparison at all, so none of the four value fields are included; each customer only hasnameandexternal_id.
data.batch
| Field | Type | Description |
|---|---|---|
current_batch | number | 1-based index of this batch. |
total_batches | number | Total number of batches for this alert run. |
total_customers | number | Total matching customers across all batches. |
batch_size | number | Number of customers in this batch (≤ 1000). |
To know when you've received every customer for a run, collect requests until current_batch === total_batches.
Possible values
Condition types
alert_condition.type:
| Value | Meaning |
|---|---|
percentage | Percentage change between previous_date and date. |
absolute | Absolute change between previous_date and date. |
treshold | A fixed threshold comparison on the current value. |
attribute_change | Fires when a customer's attribute (a custom property) changes. Customer entries carry only current_value (the new value) — no before/after comparison. |
churned | Recently churned customers. Customer entries omit the value/difference fields. |
Filter categories
alert_condition.filter_by_type determines what filter_by refers to:
filter_by_type | filter_by value | Meaning |
|---|---|---|
scoring | engagementscore | Health Score. |
scoring | fitscore | Fit Score. |
churn_risk | churn_risk | Churn risk level. |
property | a numeric property id (as a string, e.g. "42") | One of your custom customer properties. |
churned | churned | Churned status. |
attribute_changealerts always usefilter_by_type: "property", withfilter_byset to the numeric property id (as a string). Theoperatorandvalueare then evaluated against the property's changed-to value — including non-numeric operators such asKNOWN,UNKNOWN,LIKE,NLIKE,IN, andNIN.
Operators
alert_condition.operator is always UPPERCASE:
| Operator | Meaning |
|---|---|
LT | Less than (<) |
LTE | Less than or equal to (≤) |
GT | Greater than (>) |
GTE | Greater than or equal to (≥) |
EQ | Equal to (=) |
NEQ | Not equal to (≠) |
LIKE | Contains |
NLIKE | Does not contain |
IN | Is one of |
NIN | Is none of |
GTD | More than (days, for date fields) |
LTD | Less than (days, for date fields) |
KNOWN | Is known (value present) |
UNKNOWN | Is unknown (value absent) |
Verifying the signature
Every request includes an X-customerscore-HMAC header so you can confirm it came from CustomerScore and was not tampered with. The value is:
sha256=<hex>where <hex> is the HMAC-SHA256 of the raw request body (the exact bytes you received, before any JSON parsing), keyed with your webhook's secret key.
To verify:
- Read the raw request body as a string (do not re-serialize the parsed JSON — key ordering/whitespace must match exactly).
- Compute
HMAC-SHA256(secret_key, rawBody)and hex-encode it. - Compare it to the hex portion of the
X-customerscore-HMACheader using a constant-time comparison.
Node.js / Express example:
const crypto = require("node:crypto");
// Mount with a raw body parser for this route so you keep the exact bytes:
// app.post('/webhooks/customerscore', express.raw({ type: 'application/json' }), handler)
function handler(req, res) {
const secret = process.env.CUSTOMERSCORE_WEBHOOK_SECRET;
const rawBody = req.body; // a Buffer, thanks to express.raw
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const received = req.get("X-customerscore-HMAC") || "";
const valid =
expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!valid) return res.status(401).send("invalid signature");
const payload = JSON.parse(rawBody.toString("utf8"));
// ... handle payload.data ...
res.sendStatus(200);
}Example payloads
Standard alert (Health Score threshold)
{
"event": "alert.sent",
"timestamp": "2025-12-31T23:59:59.999Z",
"data": {
"date": "2025-12-31",
"previous_date": "2025-12-30",
"alert": {
"name": "High Risk Customers",
"description": "Alert triggered when customers score below 40",
"emails": ["csm@example.com"],
"alert_condition": {
"type": "treshold",
"filter_by_type": "scoring",
"filter_by": "engagementscore",
"operator": "LT",
"value": "40"
},
"segment": { "name": "Enterprise Customers" }
},
"customers": [
{
"name": "Acme Corp",
"external_id": "acme-001",
"current_value": 35,
"previous_value": 45,
"difference_abs": -10,
"difference_pct": -0.22
},
{
"name": "Beta Industries",
"external_id": "beta-002",
"current_value": 28,
"previous_value": 42,
"difference_abs": -14,
"difference_pct": -0.33
}
],
"batch": {
"current_batch": 1,
"total_batches": 1,
"total_customers": 2,
"batch_size": 2
}
}
}Attribute change alert
For attribute_change alerts, customer entries carry only current_value (the new attribute value, which may be a string) — there is no before/after comparison:
{
"event": "alert.sent",
"timestamp": "2025-12-31T23:59:59.999Z",
"data": {
"date": "2025-12-31",
"previous_date": "2025-12-30",
"alert": {
"name": "Plan Upgraded to Enterprise",
"description": "Customers whose plan attribute changed to enterprise",
"emails": ["csm@example.com"],
"alert_condition": {
"type": "attribute_change",
"filter_by_type": "property",
"filter_by": "42",
"operator": "EQ",
"value": "enterprise"
},
"segment": { "name": null }
},
"customers": [
{
"name": "Acme Corp",
"external_id": "acme-001",
"current_value": "enterprise"
},
{
"name": "Beta Industries",
"external_id": "beta-002",
"current_value": "enterprise"
}
],
"batch": {
"current_batch": 1,
"total_batches": 1,
"total_customers": 2,
"batch_size": 2
}
}
}Churned alert
For churned alerts, customer entries omit the value/difference fields:
{
"event": "alert.sent",
"timestamp": "2025-12-31T23:59:59.999Z",
"data": {
"date": "2025-12-31",
"previous_date": "2025-12-30",
"alert": {
"name": "Recently Churned",
"description": "Customers that churned since the last sync",
"emails": ["csm@example.com"],
"alert_condition": {
"type": "churned",
"filter_by_type": "churned",
"filter_by": "churned",
"operator": "EQ",
"value": "true"
},
"segment": { "name": null }
},
"customers": [
{ "name": "Gamma LLC", "external_id": "gamma-003" },
{ "name": "Delta Co", "external_id": "delta-004" }
],
"batch": {
"current_batch": 1,
"total_batches": 1,
"total_customers": 2,
"batch_size": 2
}
}
}